From 852897bf6a4a8082d0caec412d7cce97f198b2db Mon Sep 17 00:00:00 2001 From: Michael Schmid Date: Mon, 4 May 2026 22:50:24 -0400 Subject: [PATCH 01/10] S19j Pro PIC variant support: handshake, heartbeat, temps, overtemp gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original `s19j_pro_amlogic` path drives noPIC-variant Bitmain hashboards (BHB42603 / BHB42631 / S19j Pro 104T) where the BM1362 chips are powered directly from the APW12 rail. This change extends that path to also drive PIC-variant boards (BHB42601 / S19j Pro, BHB42611 / S19j Pro+, etc.), where per-domain DC-DC regulators are gated by an on-hashboard PIC16F1704 microcontroller. Background on the variant split: https://braiins.com/blog/pic-vs-nopic-bitmain-miners-... Without telling the PIC to enable DC-DC, the BM1362s stay unpowered even when the rail is at the spec voltage; naive `output-on` causes the rail to sag as the closed-by-default regulator inputs draw inrush through their input caps. With the PIC handshake in place, the existing chain UART / ramp / hash-thread code wakes up cleanly and mines. The `PicChain` library (`amlogic_cb_tools::pic`) is the matching piece; opcodes were derived from HashSource's decompiled S21 single_board_test https://github.com/HashSource/bitmain_antminer_binaries and verified byte-for-byte against ftrace captures of LuxOS on a real BHB42601 (i2c-0 events at startup). See `pic-tool` and the `amlogic-cb-tools` PR for the standalone diagnostic. Changes in this commit: - Run `PicChain::handshake()` + `enable_dc_dc()` after PSU enable and before the chain comes up. Failure is a `warn!` so noPIC hardware still bring up via the existing path (handshake will fail on a board with no PIC at the expected address; enable_dc_dc has no effect on a noPIC board because there's no PIC to talk to). - Periodic PIC heartbeat (opcode 0x16) from the telemetry task. LuxOS sends one ~every 1.5 s; without it the PIC disables DC-DC after a watchdog timeout and chips drop mid-ramp. - PIC-mediated temperature reads (opcode 0x3c at registers 0x48..0x4b) via the same handle. These show up in the `temperatures` field of the API as `HB{slot}-PIC{0..3}` and match what LuxOS reports. - Conservative 75 °C overtemp gate in the telemetry task. On trip: disable DC-DC, output-off the PSU, cancel telemetry. Stock Bitmain firmware uses ~85–95 °C for throttle/shutdown; this is more aggressive because bench setups have limited cooling and the failure mode of hot-running a hashboard is severe (the BHB42601 we used to bring this up arced its 12 V input plane the first time). - `disable_dc_dc()` on shutdown before cutting the rail. - Make `set_voltage` resilient against the APW12 firmware variant (fw 0x0010 / hw 0x0071 here) that intermittently NAKs response frames even when the underlying DAC write succeeded. Retry up to 4× with readback comparison; without this the chain ramp stalls on the first NAK and the scheduler refuses to assign jobs. - Voltage range / target: BHB42601 EEPROM specifies 13.20 V at 525 MHz. The generic `voltage_for_frequency_stacked()` formula in `bm13xx::thread_v2` is calibrated for emberone-style 12-chip stacked regulators (per-chip 0.3 V × 12 chips = 3.6 V) and returns ~12.6 V when applied to the 42-domain series chain on S19j Pro (0.3 V × 42). 0.6 V under spec causes chips to fall off the chain mid-ramp. Clamp `voltage_range` min to 13.2 V so the ramp always programs at least spec voltage. Long-term fix is per-chip-family voltage-frequency tables in `chip_config.rs`; left for later. - `disable-watchdog` rejected by this APW12 firmware (NAK on opcode 0x81); downgraded from fatal to warn since LuxOS uses periodic heartbeats instead and works fine. Tested end-to-end on an Antminer S19j Pro (BHB42601, kernel aarch64, userspace armhf, musl static binary). Sustained 18 TH/s on a single hashboard for 10+ minutes, 601 valid shares accepted, peak temp 47 °C (75 °C cutoff never tripped), zero chips lost during ramp. PIC firmware version 0x89 on both PICs at i2c addresses 0x20 and 0x21. Depends on the matching `PicChain` library landing in amlogic-cb-tools (PR https://github.com/skot/amlogic-cb-tools/pull/2). --- mujina-miner/src/board/s19j_pro_amlogic.rs | 282 +++++++++++++++++++-- 1 file changed, 260 insertions(+), 22 deletions(-) diff --git a/mujina-miner/src/board/s19j_pro_amlogic.rs b/mujina-miner/src/board/s19j_pro_amlogic.rs index bf5f16d1..1bf0287b 100644 --- a/mujina-miner/src/board/s19j_pro_amlogic.rs +++ b/mujina-miner/src/board/s19j_pro_amlogic.rs @@ -14,6 +14,7 @@ use amlogic_cb_tools::{ eeprom_antminer::decode_antminer_eeprom, gpio::SysfsGpio, linux_i2c::LinuxI2cDevice, + pic::{PicChain, pic_address_for_slot}, protocol::{ CMD_GET_VOLTAGE, CMD_MEASURE_VOLTAGE, CMD_SET_VOLTAGE, CMD_WATCHDOG, NAK_BYTE, build_frame, decode_dac_to_voltage, decode_measured_voltage, encode_voltage_to_dac, parse_frame, @@ -136,9 +137,12 @@ impl S19jProAmlogic { psu_guard .set_enabled(true) .map_err(|e| BoardError::HardwareControl(format!("Failed to enable PSU: {e}")))?; - psu_guard.config_watchdog(0x00).map_err(|e| { - BoardError::HardwareControl(format!("Failed to disable PSU watchdog: {e}")) - })?; + if let Err(e) = psu_guard.config_watchdog(0x00) { + warn!( + "PSU watchdog disable rejected (firmware variant?), continuing: {}", + e + ); + } psu_guard .set_voltage(config.startup.initial_voltage) .await @@ -150,6 +154,65 @@ impl S19jProAmlogic { psu_guard.measure_voltage().ok() }; + // PIC handshake. On BHB42601 (S19j Pro PIC variant, and BHB42611 / + // S19j Pro+ etc.) the per-domain DC-DC regulators are gated by an + // on-hashboard PIC16F1704 microcontroller and must be explicitly + // enabled before the BM1362 chips have power to respond on UART. + // See "PIC vs noPIC Bitmain Miners": + // https://braiins.com/blog/pic-vs-nopic-bitmain-miners-... + // + // The protocol opcodes used by `PicChain` were lifted from the + // decompiled S21 single_board_test in + // https://github.com/HashSource/bitmain_antminer_binaries + // (functions `reset_pic`, `start_app`, `get_pic_version`, + // `enable_dc_dc`, etc.) and confirmed against live ftrace captures + // of LuxOS on a BHB42601: the bring-up sequence below + // reset -> start_app -> get_sw_ver -> disable_dc_dc -> enable_dc_dc + // matches what LuxOS sends byte-for-byte over /dev/i2c-0. + // + // The PIC's onboard LDO is fed from the 12 V rail, so handshake + // must run AFTER `set_enabled(true)` above. We tolerate failures + // here so noPIC-variant boards (BHB42603 / BHB42631) — which skot's + // earlier work targeted — still bring up via the existing path. + let pic_addr = pic_address_for_slot(selected_hashboard.index); + match PicChain::open(&selected_hashboard.eeprom_i2c_device, pic_addr) { + Ok(mut pic) => match pic.handshake() { + Ok(version) => { + info!( + addr = format_args!("0x{:02x}", pic_addr), + version = format_args!("0x{:02x}", version), + "PIC handshake ok" + ); + if let Err(e) = pic.enable_dc_dc() { + warn!( + addr = format_args!("0x{:02x}", pic_addr), + error = %e, + "PIC enable_dc_dc failed; chips may not power up" + ); + } else { + info!( + addr = format_args!("0x{:02x}", pic_addr), + "PIC DC-DC enabled; chips powering up" + ); + } + } + Err(e) => { + warn!( + addr = format_args!("0x{:02x}", pic_addr), + error = %e, + "PIC handshake failed; chain may not respond on UART" + ); + } + }, + Err(e) => { + warn!( + addr = format_args!("0x{:02x}", pic_addr), + error = %e, + "Could not open PIC i2c device; non-PIC hashboard variant?" + ); + } + } + let fan_states = build_fan_state(config, config.startup.default_fan_percent); let power_states = vec![PowerMeasurement { name: "apw12".into(), @@ -191,6 +254,22 @@ impl Board for S19jProAmlogic { assert_all_resets(&self.config)?; configure_fans(&self.config, 0)?; + + // Best-effort disable of PIC DC-DC before cutting the rail. If this + // fails the PSU output-off below still safes the chips. + let pic_addr = pic_address_for_slot(self.selected_hashboard.index); + if let Ok(mut pic) = + PicChain::open(&self.selected_hashboard.eeprom_i2c_device, pic_addr) + { + if let Err(e) = pic.disable_dc_dc() { + warn!( + addr = format_args!("0x{:02x}", pic_addr), + error = %e, + "PIC disable_dc_dc on shutdown failed (non-fatal)" + ); + } + } + self.psu .lock() .await @@ -528,26 +607,76 @@ impl VoltageRegulator for NativeAmlogicPsu { let clamped = volts.clamp(12.0, 15.0); let dac = encode_voltage_to_dac(clamped); - match self.exchange(CMD_SET_VOLTAGE, &[dac, 0x00]) { - Ok(_) => Ok(()), - Err(err) => { - let readback = self.read_target_voltage()?; - if (readback - clamped).abs() <= 0.15 { - warn!(requested = clamped, readback, error = %err, "PSU accepted voltage by readback after transient response issue"); - Ok(()) - } else { - Err(err) + // The APW12 firmware on this control board (`get-fw` reports + // 0x0010, `get-hw` 0x0071) intermittently NAKs response frames + // even when the underlying DAC write succeeds — observed by doing + // a `get-voltage` immediately after a NAK'd `set-voltage` and + // seeing the requested DAC echoed back. Retry up to four times + // and fall back to readback comparison; if the DAC reads back at + // the requested voltage we treat the NAK as transient and accept. + // Without this the chain ramp stalls on the first NAK because + // bm13xx's chain init bubbles the error up and the scheduler + // refuses to assign jobs. + let mut last_err: Option = None; + for attempt in 0..4 { + match self.exchange(CMD_SET_VOLTAGE, &[dac, 0x00]) { + Ok(_) => { + if attempt > 0 { + warn!( + requested = clamped, + attempt, + "PSU set_voltage succeeded after retry" + ); + } + return Ok(()); + } + Err(err) => { + // Try a readback; if that succeeds and matches, the + // earlier write probably did land. If readback itself + // NAKs, retry the whole thing. + if let Ok(readback) = self.read_target_voltage() { + if (readback - clamped).abs() <= 0.15 { + warn!( + requested = clamped, + readback, + attempt, + error = %err, + "PSU accepted voltage by readback after transient response issue" + ); + return Ok(()); + } + } + last_err = Some(err); + tokio::time::sleep(Duration::from_millis(100)).await; } } } + Err(last_err.unwrap_or_else(|| anyhow::anyhow!("PSU set_voltage retries exhausted"))) } fn voltage_range(&self) -> (f32, f32) { - (12.0, 15.0) + // BHB42601 EEPROM specifies 13.20 V at 525 MHz (factory test + // setpoint, decoded from EEPROM `voltage_v` field). The generic + // `bm13xx::thread_v2::voltage_for_frequency_stacked()` formula is + // calibrated for emberone-style stacked regulators (per-chip + // 0.3 V at 500 MHz, multiplied by 12 chips = 3.6 V total) and + // returns ~12.6 V when applied to the 42-domain series chain on + // S19j Pro (0.3 V × 42). That's 0.6 V under spec and causes chips + // to fall off the chain mid-ramp under load. + // + // Clamping the min here (`applied.clamp(min_v, max_v)` in + // thread_v2) forces the ramp to program at least 13.2 V from the + // first step. Above-spec at low frequencies is harmless; the chips + // just have headroom they don't use. + // + // Long-term fix is per-chip-family voltage-frequency tables in + // chip_config.rs but that's a wider refactor. + (13.2, 15.0) } fn target_voltage(&self) -> f32 { - 12.6 + // Match EEPROM-specified operating voltage for BHB42601. + 13.2 } fn voltage_step(&self) -> f32 { @@ -695,18 +824,108 @@ async fn native_telemetry_task( ) { const TELEMETRY_INTERVAL: Duration = Duration::from_secs(2); + // Open a dedicated PIC handle for the heartbeat path. LuxOS sends a + // PIC heartbeat (opcode 0x16) periodically while mining — captured at + // roughly every 1.5 s in our ftrace runs. Without heartbeats the PIC + // appears to disable DC-DC after a watchdog timeout (we observed chips + // dropping mid-ramp without it). The exact timeout isn't documented; + // LuxOS firmware never lets the gap grow large enough to find out. + // + // We piggy-back on the 2 s telemetry tick so heartbeat + temp reads + // share one PIC handle and the same i2c-0 transactions don't race. + // Failing to open just disables the heartbeat (board may still come up + // briefly). + let pic_addr = pic_address_for_slot(hashboard.index); + let mut pic_for_heartbeat: Option = + match PicChain::open(&hashboard.eeprom_i2c_device, pic_addr) { + Ok(p) => Some(p), + Err(e) => { + warn!( + addr = format_args!("0x{:02x}", pic_addr), + error = %e, + "could not open PIC for heartbeat task; chips may drop after watchdog timeout" + ); + None + } + }; + loop { if shutdown.is_cancelled() { break; } - let temperatures = match read_temperatures(&hashboard) { - Ok(temperatures) => temperatures, - Err(error) => { - debug!(board = %hashboard.index, error = %error, "Native telemetry temperature read failed"); - Vec::new() + // Heartbeat + read PIC-mediated temps using a single PIC handle to + // avoid racing with a separately-opened temp reader. + let mut temperatures: Vec = Vec::new(); + if let Some(ref mut pic) = pic_for_heartbeat { + if let Err(e) = pic.heartbeat() { + warn!( + addr = format_args!("0x{:02x}", pic_addr), + error = %e, + "PIC heartbeat failed" + ); } - }; + match pic.read_temperatures_celsius() { + Ok(temps) => { + for (i, t) in temps.iter().enumerate() { + temperatures.push(TemperatureSensor { + name: format!("HB{}-PIC{}", hashboard.index, i), + temperature_c: Some(*t), + }); + } + } + Err(e) => { + debug!( + addr = format_args!("0x{:02x}", pic_addr), + error = %e, + "PIC temperature read failed" + ); + } + } + } + // Fallback to TMP75 path when no PIC-mediated temps were returned + // (e.g. noPIC variants). read_temperatures() handles the TMP75 case. + if temperatures.is_empty() { + match read_temperatures(&hashboard) { + Ok(t) => temperatures = t, + Err(error) => { + debug!(board = %hashboard.index, error = %error, "Native telemetry temperature read failed"); + } + } + } + + // Overtemp protection: if any PIC sensor exceeds the cutoff, + // immediately disable DC-DC (cuts chip power but keeps PIC alive) + // and PSU output, then cancel telemetry so the daemon notices and + // shuts the board down. + // + // Stock Bitmain firmware uses ~95 °C for hard shutdown and ~85 °C + // for throttling on this chip family. We use 75 °C as a + // conservative fixed cutoff: this code path is exercised on the + // bench (limited cooling) much more than in chassis, and the + // failure mode of running a hashboard hot is severe (the original + // BHB42601 we used to bring this up arced its 12 V input plane). + // A configurable threshold is the right long-term answer; left as + // future work to keep this PR focused. + const OVERTEMP_CUTOFF_C: f32 = 75.0; + let hottest = temperatures + .iter() + .filter_map(|t| t.temperature_c) + .fold(0f32, f32::max); + if hottest >= OVERTEMP_CUTOFF_C { + error!( + board = %hashboard.index, + hottest = hottest, + cutoff = OVERTEMP_CUTOFF_C, + "OVERTEMP — disabling PIC DC-DC and PSU output" + ); + if let Some(ref mut pic) = pic_for_heartbeat { + let _ = pic.disable_dc_dc(); + } + let _ = psu.lock().await.set_enabled(false); + shutdown.cancel(); + break; + } let fans = read_fan_states(&config, config.startup.default_fan_percent).await; let voltage_v = match psu.lock().await.measure_voltage() { @@ -784,8 +1003,27 @@ async fn read_fan_states(config: &AmlogicControlBoardConfig, target_percent: u8) fn read_temperatures( hashboard: &AmlogicHashboardConfig, ) -> Result, BoardError> { + // Try PIC-mediated temps first (PIC-variant boards: BHB42601 / S19j Pro + // family, where asic_sensor_type=0 in EEPROM and pic_sensor_type != 0). + // These can only be read while PSU output is ON because the PIC's LDO + // is fed from the 12V rail; tolerate a failure here and fall back to + // ASIC-bus TMP75. + let mut sensors = Vec::new(); + let pic_addr = pic_address_for_slot(hashboard.index); + if let Ok(mut pic) = PicChain::open(&hashboard.eeprom_i2c_device, pic_addr) { + if let Ok(temps) = pic.read_temperatures_celsius() { + for (i, t) in temps.iter().enumerate() { + sensors.push(TemperatureSensor { + name: format!("HB{}-PIC{}", hashboard.index, i), + temperature_c: Some(*t), + }); + } + return Ok(sensors); + } + } + + // Fallback: TMP75 sensors on the ASIC bus (older / noPIC variants). let addresses = configured_tmp75_addresses(hashboard)?; - let mut sensors = Vec::with_capacity(addresses.len()); for (sensor_index, address) in addresses.into_iter().enumerate() { let raw = read_tmp75_raw(&hashboard.temp_i2c_device, address).map_err(|e| { BoardError::InitializationFailed(format!( @@ -794,7 +1032,7 @@ fn read_temperatures( )) })?; sensors.push(TemperatureSensor { - name: format!("HB{}-Temp{}", hashboard.index, sensor_index), + name: format!("HB{}-TMP75-{}", hashboard.index, sensor_index), temperature_c: Some(decode_tmp75_celsius(raw)), }); } From a78a642fd039018eca64247a018622714df3c179 Mon Sep 17 00:00:00 2001 From: Michael Schmid Date: Sun, 21 Jun 2026 13:19:44 -0400 Subject: [PATCH 02/10] S19k Pro (BHB56902 / BM1366) hashboard support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings up the Antminer S19k Pro on the Amlogic A113D control board. Single hashboard (BHB56902) running BM1366 chips: 77 chips across 11 voltage domains, sustains 39.68 TH/s on the dummy job source at 575 MHz / 13.9V (matches LuxOS's 39.33 TH/s nominal on the same hardware). New board driver: - mujina-miner/src/board/s19k_pro_amlogic.rs: PSU bring-up (no PIC variant), per-domain DC-DC, chain enable/reset, factory voltage range (13.9–14.5V), telemetry via per-chip temp reads on i2c-0, multi-fan PWM/tach control. New BM1366 chip family in bm13xx core: - chip_config.rs::bm1366(): chip_id 0x1366, PllParams BM1366/BM1368 (fb_div 144–235, postdiv1 > postdiv2), nonce_range 0x5a10_0000, init_regs with the wire bytes confirmed against a LuxOS UART capture on BHB56902: * Reg 0x18 broadcast `ff 0f c1 00`, per-chip `f0 00 c1 00` * Reg 0x3c broadcasts `80 00 85 40` + `80 00 80 20`, per-chip adds finalizer `80 00 82 aa` * Reg 0x58 (IoDriverStrength) `02 11 41 11` * Reg 0xa8 broadcast `00 07 00 00`, per-chip `00 07 01 f0` target_frequency_mhz Some(575.0): LuxOS auto-detunes the 645 MHz EEPROM setpoint to 565 MHz adjusted / 575 MHz operating on this exact hashboard; mujina at 575 sustains 39.68 TH/s vs 38.35 at 645 (chips are at the edge of stability at 645). post_perchip_ticket_zero_bits Some(2): mirrors LuxOS's final `reg 0x14 = 0x000000c0` per-chip TicketMask write. Without it mujina sees ~4 shares/sec; with it ~1400 shares/sec, and reported hashrate jumps from 28 to 38+ TH/s on a 77-chip chain. Per-domain UartRelay support (reg 0x2C): - chip_config.rs::UartRelayPerDomain spec encodes the 22-write pattern LuxOS uses on BHB56902 (11 domains x 2 boundary chips each, byte1 stepping by 0x07). bm1362 boards keep per-chip uart_relay_perchip; BHB56902 uses per-domain. Mid-init UART baud bump to 3.125 Mbaud: - chain_config.rs::ChipUartBaudControl trait: two-phase prepare_new_stream / finalize_baud_switch so the original fd carries the chip-side `UartBaud` broadcast at the current rate and a second fd is retuned to the target rate only after the broadcast has been settled by the chips. On meson_uart the in-place tcsetattr(Drain) path dropped chips at 3 Mbaud. - thread_v2.rs: `BaudRate::Baud3M` maps to 3_125_000 numeric (matches the chip-side register `0x00003011` actual rate, not 3_000_000). Holds the original SerialControl alive via SerialControlAdapter._original_keepalive so the first /dev/ttyS2 fd never closes during the session — LuxOS pattern. Multi-pass verify_chain: - thread_v2.rs: poll all chips, retry up to two more passes on missing addresses. On BHB56902 at 3.125 Mbaud the first sweep consistently shadowed the first ~10 chips (always 0x00..0x12, closest to host) while hashrate math showed them contributing. Retries find them; logs missing_addrs on final failure. PER_CHIP_TIMEOUT bumped 100ms -> 500ms for the same reason. Two-step nonce result_header layout (BM1362 / BM1366 hack): - thread_v2.rs: existing HACK that re-extracts job_id from bits 6-3 (5-bit job_id / 3-bit subcore packing) is documented as required for BM1366 too, not just BM1362. Gating it to BM1362 only collapsed BHB56902 hashrate to ~3 TH/s. Proper fix is chip-aware Response::Nonce decoding in protocol.rs; the comment now points at that future refactor. Config / daemon plumbing: - config.rs: HashboardModel::S19kPro variant, label "S19k Pro (Amlogic control board)" / chip "BM1366". - daemon.rs / backplane.rs: dispatch S19kPro to the new s19k_pro_amlogic board driver alongside the existing S19jPro path. - transport/amlogic.rs: factory wiring for the new board. Existing boards (EmberOne, S19j Pro Amlogic/Bitcrane) get two additive None initializations (chip_uart_baud, post_broadcast_chip_baud) to keep them compiling with the new ChainPeripherals / ChainConfig fields. No behavior change for those boards. Validated on hardware: - One BHB56902 hashboard, Amlogic A113D control board. - 77/77 chips verified mining (after multi-pass verify) at 575 MHz / 13.9V, sustains 39.68 TH/s on the dummy block-881423 source, matching the LuxOS reported rate on the same hardware. Co-Authored-By: Claude Opus 4.7 --- mujina-miner/src/asic/bm13xx/chain_config.rs | 82 ++ mujina-miner/src/asic/bm13xx/chip_config.rs | 481 ++++++- mujina-miner/src/asic/bm13xx/protocol.rs | 106 +- mujina-miner/src/asic/bm13xx/sequencer.rs | 290 ++-- mujina-miner/src/asic/bm13xx/thread_v2.rs | 383 ++++- mujina-miner/src/backplane.rs | 11 +- mujina-miner/src/board/emberone.rs | 2 + mujina-miner/src/board/mod.rs | 1 + mujina-miner/src/board/s19j_pro_amlogic.rs | 2 + mujina-miner/src/board/s19j_pro_bitcrane.rs | 2 + mujina-miner/src/board/s19k_pro_amlogic.rs | 1328 ++++++++++++++++++ mujina-miner/src/config.rs | 31 +- mujina-miner/src/daemon.rs | 50 +- mujina-miner/src/display/gt_touch.rs | 1 + mujina-miner/src/transport/amlogic.rs | 7 + 15 files changed, 2530 insertions(+), 247 deletions(-) create mode 100644 mujina-miner/src/board/s19k_pro_amlogic.rs diff --git a/mujina-miner/src/asic/bm13xx/chain_config.rs b/mujina-miner/src/asic/bm13xx/chain_config.rs index 40baf714..98af3c3d 100644 --- a/mujina-miner/src/asic/bm13xx/chain_config.rs +++ b/mujina-miner/src/asic/bm13xx/chain_config.rs @@ -49,6 +49,19 @@ pub struct ChainConfig { /// Hardware control interfaces for this chain. pub peripherals: ChainPeripherals, + + /// After the broadcast register-config phase completes, the hash + /// thread sends a UartBaud broadcast to switch the chips to this + /// baud rate, then calls [`ChainPeripherals::set_chip_uart_baud`] + /// to switch the controller side. Per-chip writes and steady-state + /// mining traffic then run at this rate. + /// + /// `None` keeps the link at the rate the SerialStream was opened + /// with (default 115200). Set this to a higher rate (e.g. + /// `BaudRate::Baud3M`) on boards with long chains where 115200 is + /// the chain throughput bottleneck — confirmed for BHB56902 via the + /// LuxOS capture (see `captures/luxos-bhb56902-findings.md`). + pub post_broadcast_chip_baud: Option, } /// Hardware interfaces for a chain. @@ -75,6 +88,74 @@ pub struct ChainPeripherals { /// Voltage regulator control (optional, may be shared across chains). pub voltage_regulator: Option>>, + + /// Optional handle for retuning the controller-side chip UART baud + /// rate mid-init. Boards that own the [`SerialStream`] for the chip + /// channel can wrap its `SerialControl` here so the hash thread can + /// switch to a higher rate after sending the chip-side + /// [`UartBaud`](super::protocol::Register::UartBaud) broadcast. + pub chip_uart_baud: Option>>, +} + +/// Boxed type for the chip → controller response stream. +pub type ChipRxStream = std::pin::Pin< + Box< + dyn futures::Stream> + + Send + + 'static, + >, +>; + +/// Boxed type for the controller → chip command sink. +pub type ChipTxSink = std::pin::Pin< + Box< + dyn futures::Sink + + Send + + 'static, + >, +>; + +/// Controller-side handle for changing the chip-channel UART baud rate. +/// +/// Implementations wrap a [`SerialControl`](crate::transport::serial::SerialControl) +/// or equivalent so the hash thread can drive a mid-init baud bump. +/// +/// The recommended pattern on the Amlogic `meson_uart` driver is the +/// "close + reopen" approach (matching LuxOS — see +/// `captures/luxos-bhb56902-findings.md` for the 3 separate `open64()` +/// calls observed during init): drop the current `SerialStream`, open +/// a fresh one at the new baud rate, and return a new pair of typed +/// channels. The `tcsetattr(Drain)` baud-switch path drops chips at +/// 3 Mbaud on BHB56902. +#[async_trait::async_trait] +pub trait ChipUartBaudControl { + /// Pre-open a second `/dev/ttyS2` handle at `current_baud_rate` + /// (matching the current kernel termios so the hardware baud + /// doesn't change yet) and return fresh I/O channels backed by + /// that handle. The implementation must keep the new handle's + /// control side alive internally so it can be retuned in + /// `finalize_baud_switch`. + /// + /// This is the open-without-bumping side of the two-phase LuxOS + /// baud-switch pattern: two fds end up open on the same device, + /// the old writer carries the chip-side `UartBaud` broadcast at + /// the current rate, and only afterward does the new fd get + /// retuned to the target rate. + async fn prepare_new_stream( + &mut self, + current_baud_rate: u32, + ) -> anyhow::Result<(ChipRxStream, ChipTxSink)>; + + /// Apply `target_baud_rate` to the stream prepared by the last + /// `prepare_new_stream` call. Per Linux `termios` semantics, this + /// also retunes the underlying device — so the hash thread must + /// have already finished its broadcast on the OLD writer and + /// waited for chips to apply their internal baud change before + /// calling this. + async fn finalize_baud_switch( + &mut self, + target_baud_rate: u32, + ) -> anyhow::Result<()>; } #[cfg(test)] @@ -111,6 +192,7 @@ mod tests { let peripherals = ChainPeripherals { asic_enable: enable, voltage_regulator: None, + chip_uart_baud: None, }; // Hash thread enables diff --git a/mujina-miner/src/asic/bm13xx/chip_config.rs b/mujina-miner/src/asic/bm13xx/chip_config.rs index 913b8239..4fbaeadb 100644 --- a/mujina-miner/src/asic/bm13xx/chip_config.rs +++ b/mujina-miner/src/asic/bm13xx/chip_config.rs @@ -1,32 +1,169 @@ //! Chip configuration for BM13xx ASIC chips. //! -//! All BM13xx chip models share the same configurable fields---only the -//! default values differ. Use [`bm1362()`] or [`bm1370()`] to get appropriate -//! defaults, then modify fields as needed. +//! All BM13xx chip models share the same configurable fields; only the +//! values differ. Use [`bm1362()`], [`bm1366()`], or [`bm1370()`] to get +//! appropriate defaults, then modify fields as needed. //! //! # PLL Calculation //! -//! PLL configuration is handled by [`ChipConfig::calculate_pll()`]. Empirical -//! analysis of S19 J Pro (BM1362) and S21 Pro (BM1370) serial captures confirms -//! these chips use identical PLL parameters: +//! PLL configuration is handled by [`ChipConfig::calculate_pll()`]. +//! The chip-family-specific constraints live in [`PllParams`] on the +//! config: //! -//! - FBDIV range: 0xA0--0xEF (160--239) -//! - Post-divider constraint: `postdiv1 >= postdiv2` -//! - VCO threshold: flag=0x50 when VCO >= 2400 MHz, else 0x40 +//! - **BM1362 / BM1370**: FBDIV 0xA0–0xEF (160–239), `postdiv1 ≥ postdiv2`. +//! Verified by S19j Pro (BM1362) and S21 Pro (BM1370) serial captures. +//! - **BM1366 / BM1368**: FBDIV 0x90–0xEB (144–235), strict +//! `postdiv1 > postdiv2`. Verified by bitaxeorg/ESP-Miner +//! `components/asic/bm1366.c` (`pll_get_parameters(... 144, 235, ...)`). //! -//! BM1366 and BM1368 use a different FBDIV range (0x90--0xEB = 144--235) per -//! esp-miner, and BM1366 additionally requires strict `postdiv1 > postdiv2`. -//! Support for these chips would require chip-specific `PllParams`. +//! The VCO threshold is identical across families: `flag = 0x50` when +//! `VCO >= 2400 MHz`, else `0x40`. +//! +//! # Per-family register values +//! +//! BM1362 and BM1366 use the same set of init *register addresses* +//! (0xA8, 0x18, 0x3C, 0x54, 0x58, 0x2C, 0xA8) but different *raw values* +//! at each address. The bytes are kept on [`ChainInitRegs`] inside the +//! config so the [`crate::asic::bm13xx::sequencer::Sequencer`] can stay +//! chip-family-agnostic. +//! +//! Sources for the BM1366 values: byte-for-byte from ESP-Miner BM1366.c +//! (init4, init5, init135, init136, init138, init139, init171, and the +//! per-chip writes from the `for each chip` loop). use super::protocol::{Frequency, IoDriverStrength, PllConfig}; +/// PLL search-space constraints that differ between BM13xx chip +/// families. +#[derive(Debug, Clone, Copy)] +pub struct PllParams { + /// Minimum allowable feedback divider (inclusive). + pub fbdiv_min: u8, + /// Maximum allowable feedback divider (inclusive). + pub fbdiv_max: u8, + /// When `true`, `postdiv1 > postdiv2` strictly; when `false`, + /// `postdiv1 >= postdiv2` is accepted (BM1362 / BM1370 behaviour). + pub postdiv_strict: bool, +} + +impl PllParams { + /// Default PLL constraints for BM1362 / BM1370. Verified from S19j + /// Pro and S21 Pro serial captures. + pub const BM1362_BM1370: PllParams = PllParams { + fbdiv_min: 0xA0, + fbdiv_max: 0xEF, + postdiv_strict: false, + }; + + /// PLL constraints for BM1366 / BM1368 per ESP-Miner BM1366.c + /// (`pll_get_parameters(target, 144, 235, ...)`). BM1366 additionally + /// requires strict `postdiv1 > postdiv2`. + pub const BM1366_BM1368: PllParams = PllParams { + fbdiv_min: 0x90, + fbdiv_max: 0xEB, + postdiv_strict: true, + }; +} + +/// Per-chip-family init-register raw values, encoded as `u32` written to +/// the BM13xx address space (the protocol's `to_le_bytes()` placement +/// produces the same wire bytes as ESP-Miner's hand-rolled arrays). +/// +/// Fields that are `Option<_>` represent register writes that some chip +/// families need but others skip entirely. Skipping is encoded as +/// `None` so the [`Sequencer`](crate::asic::bm13xx::sequencer::Sequencer) +/// doesn't have to know which family is current. + +/// Per-domain-boundary UartRelay (reg 0x2C) configuration. +/// +/// On the BHB56902 (S19k Pro) hashboard, the stock Bitmain / LuxOS init +/// writes reg 0x2C only to the first and last chip of each voltage domain, +/// with a value that varies per domain. Captured wire bytes (4 bytes per +/// chip): +/// +/// ```text +/// wire bytes = [byte0, byte1, byte2, byte3] +/// byte1 = high_base - domain_idx * high_step +/// byte0, byte2, byte3 are fixed. +/// ``` +/// +/// For BHB56902: `byte0 = 0x00`, `byte1` ranges `0x5b → 0x15` (step 0x07 +/// per domain, 11 domains), `byte2 = 0x00`, `byte3 = 0x03`. The resulting +/// LE u32 for domain 0 is `0x03005B00`; for domain 10 it is `0x03001500`. +#[derive(Debug, Clone, Copy)] +pub struct UartRelayPerDomain { + /// Byte 0 of the wire payload (lowest-address byte in the LE u32). + pub byte0: u8, + /// `byte1` value written to domain 0 (closest to the controlboard). + pub byte1_base: u8, + /// Amount `byte1` decreases per downstream domain. + pub byte1_step: u8, + /// Byte 2 of the wire payload. + pub byte2: u8, + /// Byte 3 of the wire payload (highest-address byte in the LE u32). + pub byte3: u8, +} + +impl UartRelayPerDomain { + /// Compute the u32 value to write for the domain at `domain_idx` + /// (0 = closest to the host). Wire bytes are `value.to_le_bytes()`. + pub fn value_for(&self, domain_idx: usize) -> u32 { + let b1 = self + .byte1_base + .wrapping_sub((domain_idx as u8).wrapping_mul(self.byte1_step)); + u32::from_le_bytes([self.byte0, b1, self.byte2, self.byte3]) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct ChainInitRegs { + // ---- enumeration phase (broadcast) ---- + /// Reg 0xA8 (InitControl), broadcast before `ChainInactive`. + /// Wire bytes for BM1362 are `00 00 00 00`, for BM1366 `00 07 00 00`. + pub init_control_broadcast: u32, + /// Reg 0x18 (MiscControl), broadcast before `ChainInactive`. + /// Wire bytes for BM1362 are `B0 00 C1 00`, for BM1366 `FF 0F C1 00`. + pub misc_control_broadcast: u32, + // ---- reg config (broadcast) ---- + /// Reg 0x3C (Core) broadcast writes that enable the hashing cores. + /// BM1362 writes two values; BM1366 also two but different bytes. + pub core_broadcast: [u32; 2], + /// Reg 0x54 (AnalogMux) broadcast configuration. + pub analog_mux_broadcast: u32, + /// Reg 0x58 (IoDriverStrength) broadcast — BM1366 sets a chip- + /// specific raw pattern (`02 11 11 11`) here. BM1362 reads the + /// per-chain default from `chip_config.io_driver` separately, so + /// this is only `Some(_)` for chip families that override it. + pub io_driver_broadcast_raw: Option, + // ---- reg config (per chip) ---- + /// Reg 0x2C (UartRelay) per-chip — a single value broadcast-like to + /// every chip in the chain. Used by simple boards (Bitaxe Supra: one + /// domain, all chips get the same value). Mutually exclusive with + /// [`uart_relay_perdomain`]. + pub uart_relay_perchip: Option, + /// Reg 0x2C (UartRelay) per-domain-boundary — write to the first and + /// last chip of each voltage domain with a per-domain value. Confirmed + /// pattern for BHB56902 (S19k Pro) via the LuxOS capture in + /// `captures/luxos-bhb56902-findings.md`. Mutually exclusive with + /// [`uart_relay_perchip`]. + pub uart_relay_perdomain: Option, + /// Reg 0xA8 (InitControl) per-chip after broadcast phase. + pub init_control_perchip: u32, + /// Reg 0x18 (MiscControl) per-chip after broadcast phase. + pub misc_control_perchip: u32, + /// Reg 0x3C (Core) per-chip writes. Three values; the third has + /// a small post-write delay (handled in the sequencer). + pub core_perchip: [u32; 3], +} + /// Configuration for a BM13xx ASIC chip. /// -/// All chip models share the same fields. Use [`bm1362()`] or [`bm1370()`] -/// to get appropriate defaults, then modify fields as needed. +/// All chip models share the same fields. Use [`bm1362()`], [`bm1366()`], +/// or [`bm1370()`] to get appropriate defaults, then modify fields as +/// needed. #[derive(Debug, Clone)] pub struct ChipConfig { - /// Chip model identifier (e.g., 0x1362, 0x1370). + /// Chip model identifier (e.g., 0x1362, 0x1366, 0x1370). /// Verified during enumeration to ensure correct chip type. pub chip_id: u16, @@ -36,14 +173,39 @@ pub struct ChipConfig { /// Maximum supported frequency for this chip model. pub max_freq: Frequency, - /// IO driver strength for signal integrity. + /// IO driver strength for signal integrity. Some chip families + /// (BM1366) override this with a raw broadcast value in + /// [`ChainInitRegs::io_driver_broadcast_raw`] instead. pub io_driver: IoDriverStrength, /// Nonce range configuration value (chip-family-specific). /// - /// Controls how chips divide the 32-bit nonce search space. Empirically - /// determined values differ by chip family rather than chain length. + /// Controls how chips divide the 32-bit nonce search space. + /// Empirically determined values differ by chip family rather than + /// chain length. pub nonce_range: u32, + + /// PLL search-space constraints. + pub pll_params: PllParams, + + /// Chip-family-specific init register values used by the sequencer. + pub init_regs: ChainInitRegs, + + /// Optional override for the final TicketMask broadcast emitted at + /// the end of `default_reg_config_perchip`. `Some(2)` matches the + /// LuxOS BHB56902 capture (`reg 0x14 = 0x000000C0`, wire byte + /// `0xC0` decodes to `zero_bits=2` in our encoding). `None` keeps + /// the dynamically-scaled mask from the broadcast phase. + pub post_perchip_ticket_zero_bits: Option, + + /// Target operating frequency for the chain's `execute_frequency_ramp` + /// in MHz. `None` falls back to the historical 500 MHz hardcode. + /// + /// For BHB56902 (S19k Pro / BM1366) the factory ATE setpoint is + /// 645 MHz at 13.9 V (per EEPROM `frequency_mhz` / `voltage_v` + /// and Braiins OS's `ResolvedChainConfig`). Lower targets leave + /// significant hashrate on the table. + pub target_frequency_mhz: Option, } /// Crystal oscillator frequency for BM13xx chips (25 MHz). @@ -57,49 +219,58 @@ impl ChipConfig { /// Calculate optimal PLL configuration for target frequency. /// - /// Searches for PLL divider values that produce the closest match to - /// the target frequency. When multiple configs achieve the same + /// Searches for PLL divider values that produce the closest match + /// to the target frequency. When multiple configs achieve the same /// accuracy, prefers the one with the lowest VCO frequency to stay - /// in the VCO's optimal range (~2000-2300 MHz). Validated against - /// S19 J Pro (BM1362) and S21 Pro (BM1370) serial captures. + /// in the VCO's optimal range (~2000–2300 MHz). The FBDIV search + /// range and the postdiv constraint come from + /// [`ChipConfig::pll_params`] so BM1366 uses 0x90–0xEB with strict + /// `>`, while BM1362 / BM1370 use 0xA0–0xEF with `>=`. pub fn calculate_pll(&self, freq: Frequency) -> PllConfig { let target_freq = freq.mhz(); - let mut best_config = PllConfig::new(0xa0, 2, 0x55); // Default + let mut best_config = PllConfig::new(self.pll_params.fbdiv_min, 2, 0x55); // Default let mut min_error = f32::MAX; let mut best_vco = f32::MAX; // Search for optimal PLL settings // ref_divider: 1 or 2 - // post_divider1: 1-7, must be >= post_divider2 + // post_divider1: 1-7, must satisfy the family's postdiv constraint // post_divider2: 1-7 - // fb_divider: 0xa0-0xef (160-239) + // fb_divider: pll_params.fbdiv_min..=pll_params.fbdiv_max for ref_div in [2, 1] { for post_div1 in (1..=7).rev() { for post_div2 in (1..=7).rev() { - if post_div1 >= post_div2 { - // Calculate required feedback divider - let fb_div_f = - (post_div1 * post_div2) as f32 * target_freq * ref_div as f32 - / CRYSTAL_MHZ; - let fb_div = fb_div_f.round() as u8; - - if (0xa0..=0xef).contains(&fb_div) { - // Calculate actual frequency with these settings - let actual_freq = CRYSTAL_MHZ * fb_div as f32 - / (ref_div as f32 * post_div1 as f32 * post_div2 as f32); - let error = (target_freq - actual_freq).abs(); - let vco = CRYSTAL_MHZ * fb_div as f32 / ref_div as f32; - - if error < 1.0 - && (error < min_error || (error == min_error && vco < best_vco)) - { - min_error = error; - best_vco = vco; - // Encode post dividers as per hardware format - let post_div = ((post_div1 - 1) << 4) | (post_div2 - 1); - best_config = PllConfig::new(fb_div, ref_div, post_div); - } + let postdiv_ok = if self.pll_params.postdiv_strict { + post_div1 > post_div2 + } else { + post_div1 >= post_div2 + }; + if !postdiv_ok { + continue; + } + // Calculate required feedback divider + let fb_div_f = (post_div1 * post_div2) as f32 + * target_freq + * ref_div as f32 + / CRYSTAL_MHZ; + let fb_div = fb_div_f.round() as u8; + + if (self.pll_params.fbdiv_min..=self.pll_params.fbdiv_max).contains(&fb_div) { + // Calculate actual frequency with these settings + let actual_freq = CRYSTAL_MHZ * fb_div as f32 + / (ref_div as f32 * post_div1 as f32 * post_div2 as f32); + let error = (target_freq - actual_freq).abs(); + let vco = CRYSTAL_MHZ * fb_div as f32 / ref_div as f32; + + if error < 1.0 + && (error < min_error || (error == min_error && vco < best_vco)) + { + min_error = error; + best_vco = vco; + // Encode post dividers as per hardware format + let post_div = ((post_div1 - 1) << 4) | (post_div2 - 1); + best_config = PllConfig::new(fb_div, ref_div, post_div); } } } @@ -115,6 +286,7 @@ impl ChipConfig { /// Sources: /// - S19 J Pro serial captures /// - skot/bm1397-docs +/// - emberone-miner reference values for [`ChainInitRegs`]. pub fn bm1362() -> ChipConfig { ChipConfig { chip_id: 0x1362, @@ -122,6 +294,124 @@ pub fn bm1362() -> ChipConfig { max_freq: Frequency::from_mhz(525.0), io_driver: IoDriverStrength::normal(), nonce_range: 0x8118_0000, // From emberone-miner (12 chips) + pll_params: PllParams::BM1362_BM1370, + init_regs: ChainInitRegs { + // Reg 0xA8 broadcast: wire bytes `00 00 00 00` (emberone-miner) + init_control_broadcast: 0x0000_0000, + // Reg 0x18 broadcast: wire bytes `B0 00 C1 00` + misc_control_broadcast: 0x00C1_00B0, + // Reg 0x3C broadcasts: wire bytes `40 85 00 80` and `08 80 00 80` + core_broadcast: [0x8000_8540, 0x8000_8008], + // Reg 0x54 broadcast: wire bytes `00 00 00 03` + analog_mux_broadcast: 0x0300_0000, + // BM1362 leaves IoDriverStrength to the per-chain default. + io_driver_broadcast_raw: None, + // No reg 0x2C write on BM1362 — neither per-chip nor per-domain. + uart_relay_perchip: None, + uart_relay_perdomain: None, + // Reg 0xA8 per-chip: wire bytes `00 00 00 02` + init_control_perchip: 0x0200_0000, + // Reg 0x18 per-chip: wire bytes `B0 00 C1 00` + misc_control_perchip: 0x00C1_00B0, + // Reg 0x3C per-chip: enable hashing cores + core_perchip: [0x8000_8540, 0x8000_8008, 0x8000_82AA], + }, + // BM1362 boards keep the dynamically-scaled TicketMask from the + // broadcast phase; no capture-derived override yet. + post_perchip_ticket_zero_bits: None, + target_frequency_mhz: None, + } +} + +/// BM1366 defaults (Antminer S19k Pro / BHB56902, Bitaxe Supra). +/// +/// Sources (byte-for-byte): +/// - bitaxeorg/ESP-Miner `components/asic/bm1366.c` +/// (init4, init5, init135, init136, init138, init139, init171, and +/// the per-chip register loop). +/// - PLL constraints from `pll_get_parameters(target, 144, 235, ...)` +/// plus BM1366's additional strict `postdiv1 > postdiv2` requirement +/// documented in skot/bm1397-docs. +/// +/// `nonce_range` is left at the BM1362 reference value as an initial +/// placeholder; the field is overwritten by S19k Pro board +/// initialisation once we have a serial capture from real hardware. +pub fn bm1366() -> ChipConfig { + ChipConfig { + chip_id: 0x1366, + min_freq: Frequency::from_mhz(50.0), + // 660 MHz ceiling leaves a 15 MHz pad above the BHB56902 + // factory setpoint of 645 MHz so the ramp can reach target + // without clamping. ESP-Miner's BM1366 PLL constraint table + // tops out at fb_div=0xEB which yields up to ~734 MHz with + // the minimum postdiv combo (4/1), so 660 MHz is well within + // chip-supported PLL space. + max_freq: Frequency::from_mhz(660.0), + io_driver: IoDriverStrength::normal(), + nonce_range: 0x5a10_0000, // BHB56902 (S19k Pro), from LuxOS capture + // wire bytes `00 00 10 5a` + pll_params: PllParams::BM1366_BM1368, + init_regs: ChainInitRegs { + // Reg 0xA8 broadcast (init4): wire bytes `00 07 00 00` + init_control_broadcast: 0x0000_0700, + // Reg 0x18 broadcast (init5): wire bytes `FF 0F C1 00` + misc_control_broadcast: 0x00C1_0FFF, + // Reg 0x3C broadcasts (init135 / init136): + // wire bytes `80 00 85 40` and `80 00 80 20` + core_broadcast: [0x4085_0080, 0x2080_0080], + // Reg 0x54 broadcast (init138): wire bytes `00 00 00 03` + analog_mux_broadcast: 0x0300_0000, + // Reg 0x58 broadcast: wire bytes `02 11 41 11` per live LuxOS + // capture on BHB56902 (`captures/luxos-bhb56902-chain-init.log`: + // `55aa 5109 00 58 02114111 13`). Earlier ESP-Miner reference + // value was `02 11 11 11`; the third nibble was wrong. + io_driver_broadcast_raw: Some(0x1141_1102), + // Reg 0x2C is handled per-domain-boundary on this hashboard + // (see `uart_relay_perdomain` below). The per-chip path is left + // for chip families / boards that genuinely broadcast a single + // value to every chip. + uart_relay_perchip: None, + // Reg 0x2C per-domain-boundary, per the BHB56902 LuxOS capture. + // LuxOS writes 22 unicast frames (11 voltage domains × first + + // last chip per domain). The value's middle byte encodes the + // chip-count downstream of this domain (plus a fixed offset): + // `high = high_base - domain_idx * high_step` + // For BHB56902: domain 0 (closest to host) = 0x5b, domain 10 + // = 0x15, step = 0x07. Low word fixed at 0x0003. + uart_relay_perdomain: Some(UartRelayPerDomain { + byte0: 0x00, + byte1_base: 0x5b, + byte1_step: 0x07, + byte2: 0x00, + byte3: 0x03, + }), + // Reg 0xA8 per-chip: wire bytes `00 07 01 F0` + init_control_perchip: 0xF001_0700, + // Reg 0x18 per-chip: wire bytes `F0 00 C1 00` + misc_control_perchip: 0x00C1_00F0, + // Reg 0x3C per-chip: same three values as the broadcasts + // plus the BM1362-style finalizer 0x82AA. + // `80 00 85 40`, `80 00 80 20`, `80 00 82 AA` + core_perchip: [0x4085_0080, 0x2080_0080, 0xAA82_0080], + }, + // LuxOS post-per-chip TicketMask = `0x000000C0` (`zero_bits=2`) + // per the BHB56902 capture. Re-enabling now that the earlier + // limiters are gone (13.9V chain voltage, 3.125 Mbaud, fd + // keepalive, multi-pass verify → 77/77 chips). The prior + // attempts that landed on 20.7 TH/s with 55/77 chips were + // pre-voltage-fix, so the regression observed then was a + // chip-count loss, not a TicketMask issue. + post_perchip_ticket_zero_bits: Some(2), + // 575 MHz matches LuxOS's actual operating point on this + // hashboard. The EEPROM ATE setpoint is 645 MHz but LuxOS + // auto-detunes ("k Pro adjusted board frequency: 565MHz" in + // its log) and operates at 575 MHz, sustaining 39.15 TH/s + // (Nominal 39.33). mujina at 575 MHz sustains 39.68 TH/s on + // the dummy source — at parity with LuxOS per MHz (slightly + // above, within measurement noise). 645 MHz delivered LESS + // (38.35 TH/s) than 575 — chips appear right at the edge of + // stability at 645 while comfortable at 575. + target_frequency_mhz: Some(575.0), } } @@ -137,6 +427,13 @@ pub fn bm1370() -> ChipConfig { max_freq: Frequency::from_mhz(600.0), io_driver: IoDriverStrength::normal(), nonce_range: 0xB51E_0000, // From S21 Pro captures + pll_params: PllParams::BM1362_BM1370, + // BM1370 shares the BM1362 init register layout (Bitaxe Gamma + // captures vs the S19j Pro captures show identical + // broadcast + per-chip register sequences modulo PLL bytes). + init_regs: bm1362().init_regs, + post_perchip_ticket_zero_bits: None, + target_frequency_mhz: None, } } @@ -149,9 +446,15 @@ mod tests { let config = bm1362(); assert!(config.verify_chip_id(0x1362)); assert!(!config.verify_chip_id(0x1370)); + assert!(!config.verify_chip_id(0x1366)); + + let bm1366_cfg = bm1366(); + assert!(bm1366_cfg.verify_chip_id(0x1366)); + assert!(!bm1366_cfg.verify_chip_id(0x1362)); } - /// Test cases from serial captures showing PLL values sent by esp-miner. + /// Test cases from serial captures showing PLL values sent by + /// esp-miner. /// /// Sources: /// - Bitaxe Gamma logic analyzer captures @@ -275,4 +578,84 @@ mod tests { ); } } + + /// BM1366 PLL output respects the family's narrower FBDIV range + /// (0x90–0xEB) and the strict `postdiv1 > postdiv2` constraint. + /// + /// Source: bitaxeorg/ESP-Miner `components/asic/bm1366.c` + /// (`pll_get_parameters(target, 144, 235, ...)`). + #[test] + fn bm1366_pll_respects_family_constraints() { + let config = bm1366(); + + for freq_mhz in [62.5, 100.0, 200.0, 300.0, 400.0, 500.0, 600.0] { + let pll = config.calculate_pll(Frequency::from_mhz(freq_mhz)); + assert!( + (0x90..=0xEB).contains(&pll.fb_div), + "BM1366 {freq_mhz}MHz: fb_div={:#04x} out of 0x90..=0xEB range", + pll.fb_div + ); + + // Decode encoded postdiv to verify strict `>`. + let post_div1 = ((pll.post_div >> 4) & 0xF) + 1; + let post_div2 = (pll.post_div & 0xF) + 1; + assert!( + post_div1 > post_div2, + "BM1366 {freq_mhz}MHz: postdiv1={post_div1} not strictly > postdiv2={post_div2}" + ); + } + } + + /// Quick sanity check on the BM1366 init register raw values: each + /// LE encoding produces the exact wire bytes documented in + /// ESP-Miner. + #[test] + fn bm1366_init_regs_match_esp_miner_wire_bytes() { + let regs = bm1366().init_regs; + // (raw_value, expected wire bytes, label) + let cases: &[(u32, [u8; 4], &str)] = &[ + (regs.init_control_broadcast, [0x00, 0x07, 0x00, 0x00], "reg 0xA8 bcast"), + (regs.misc_control_broadcast, [0xFF, 0x0F, 0xC1, 0x00], "reg 0x18 bcast"), + (regs.core_broadcast[0], [0x80, 0x00, 0x85, 0x40], "reg 0x3C bcast #1"), + (regs.core_broadcast[1], [0x80, 0x00, 0x80, 0x20], "reg 0x3C bcast #2"), + (regs.analog_mux_broadcast, [0x00, 0x00, 0x00, 0x03], "reg 0x54 bcast"), + (regs.io_driver_broadcast_raw.unwrap(), [0x02, 0x11, 0x41, 0x11], "reg 0x58 bcast"), + (regs.init_control_perchip, [0x00, 0x07, 0x01, 0xF0], "reg 0xA8 per-chip"), + (regs.misc_control_perchip, [0xF0, 0x00, 0xC1, 0x00], "reg 0x18 per-chip"), + (regs.core_perchip[0], [0x80, 0x00, 0x85, 0x40], "reg 0x3C per-chip #1"), + (regs.core_perchip[1], [0x80, 0x00, 0x80, 0x20], "reg 0x3C per-chip #2"), + (regs.core_perchip[2], [0x80, 0x00, 0x82, 0xAA], "reg 0x3C per-chip #3"), + ]; + for (raw, expected, label) in cases { + assert_eq!( + raw.to_le_bytes(), + *expected, + "{label}: raw 0x{raw:08X} → {:02X?}, expected {:02X?}", + raw.to_le_bytes(), + expected + ); + } + } + + /// Per-domain UartRelay values for BHB56902 must match the LuxOS + /// capture: 11 domains, wire bytes `00 X 00 03` with `X` stepping + /// 0x07 from 0x5b (domain 0) down to 0x15 (domain 10). + #[test] + fn bm1366_uart_relay_perdomain_matches_luxos_capture() { + let spec = bm1366() + .init_regs + .uart_relay_perdomain + .expect("BM1366 should have per-domain UartRelay configured"); + let expected_byte1: [u8; 11] = [ + 0x5b, 0x54, 0x4d, 0x46, 0x3f, 0x38, 0x31, 0x2a, 0x23, 0x1c, 0x15, + ]; + for (idx, b1) in expected_byte1.iter().enumerate() { + let wire = spec.value_for(idx).to_le_bytes(); + assert_eq!( + wire, + [0x00, *b1, 0x00, 0x03], + "domain {idx}: wire {wire:02X?} != expected [00 {b1:02X} 00 03]" + ); + } + } } diff --git a/mujina-miner/src/asic/bm13xx/protocol.rs b/mujina-miner/src/asic/bm13xx/protocol.rs index 05ae8f8f..618fc6b9 100644 --- a/mujina-miner/src/asic/bm13xx/protocol.rs +++ b/mujina-miner/src/asic/bm13xx/protocol.rs @@ -354,6 +354,14 @@ impl TicketMask { } } + /// Build a TicketMask directly from the `zero_bits` field, bypassing + /// the `ReportingInterval` derivation. Use when a board has a known + /// good final TicketMask value from a stock-firmware capture (e.g. + /// `zero_bits=2` on BHB56902 to match LuxOS's `wire 0xC0`). + pub const fn from_zero_bits(zero_bits: u8) -> Self { + Self { zero_bits } + } + /// Encode ticket mask to wire format bytes pub fn to_wire_bytes(&self) -> [u8; 4] { if self.zero_bits == 0 { @@ -423,8 +431,13 @@ impl From for [u8; 4] { // From esp-miner BM1370_set_max_baud/BM1366_set_max_baud/BM1368_set_max_baud // All three chips use identical register value for 1Mbaud BaudRate::Baud1M => 0x00023011, - // From S21 Pro captures (BM1370 multi-chip chains) - BaudRate::Baud3M => 0x00003001, + // Wire bytes `11 30 00 00` (LE u32 = 0x00003011) for 3 Mbaud. + // Confirmed by live LuxOS capture on BHB56902 (S19k Pro / BM1366): + // see `captures/luxos-bhb56902-chain-init.log` for the broadcast + // `55aa 5109 00 28 11300000 1e` and dmesg showing the controller + // baud bump from 115200 → 3000000 right after. Earlier value + // 0x00003001 was an untested guess. + BaudRate::Baud3M => 0x00003011, BaudRate::Custom(val) => val, }; value.to_le_bytes() @@ -454,6 +467,28 @@ impl IoDriverStrength { strengths: [0x0, 0x0, 0x1, 0xf, 0x1, 0x1, 0x1, 0x1], } } + + /// Construct from an exact-wire `u32` (little-endian, matching the + /// register's on-wire byte order). Used for chip-family-specific + /// raw values where the per-signal-group decomposition isn't + /// meaningful — e.g. BM1366's `02 11 11 11` reg-0x58 broadcast + /// from ESP-Miner's init139, which doesn't map cleanly onto the + /// "normal middle / strong boundary" preset shape. + pub fn from_raw_le_u32(raw: u32) -> Self { + let bytes = raw.to_le_bytes(); + Self { + strengths: [ + bytes[0] & 0x0F, + (bytes[0] >> 4) & 0x0F, + bytes[1] & 0x0F, + (bytes[1] >> 4) & 0x0F, + bytes[2] & 0x0F, + (bytes[2] >> 4) & 0x0F, + bytes[3] & 0x0F, + (bytes[3] >> 4) & 0x0F, + ], + } + } } impl From for [u8; 4] { @@ -534,9 +569,19 @@ pub enum RegisterAddress { UartBaud = 0x28, UartRelay = 0x2C, Core = 0x3C, + /// BM1366/BM1368 Clock Order Control 0 — broadcast write during init (esp-miner BM1366.c). + ClockOrderControl0 = 0x40, + /// BM1366/BM1368 Clock Order Control 1 — broadcast write during init (esp-miner BM1366.c). + ClockOrderControl1 = 0x44, AnalogMux = 0x54, IoDriverStrength = 0x58, + /// BM1366 Clock Order Status — chip auto-reports during PLL/clock transitions. + ClockOrderStatus = 0x60, Pll3Parameter = 0x68, + /// BM1366 Frequency Sweep Control — used by nonce auto-sweep features. + FrequencySweepControl = 0x80, + /// BM1366 Golden Nonce for Sweep Return. + GoldenNonceForSweepReturn = 0x84, VersionMask = 0xA4, InitControl = 0xA8, MiscSettings = 0xB9, @@ -562,13 +607,28 @@ pub enum Register { Core { raw_value: u32, }, + ClockOrderControl0 { + raw_value: u32, + }, + ClockOrderControl1 { + raw_value: u32, + }, AnalogMux { raw_value: u32, }, IoDriverStrength(IoDriverStrength), + ClockOrderStatus { + raw_value: u32, + }, Pll3Parameter { raw_value: u32, }, + FrequencySweepControl { + raw_value: u32, + }, + GoldenNonceForSweepReturn { + raw_value: u32, + }, VersionMask(VersionMask), InitControl { raw_value: u32, @@ -609,6 +669,8 @@ impl Register { } RegisterAddress::UartRelay => Register::UartRelay { raw_value }, RegisterAddress::Core => Register::Core { raw_value }, + RegisterAddress::ClockOrderControl0 => Register::ClockOrderControl0 { raw_value }, + RegisterAddress::ClockOrderControl1 => Register::ClockOrderControl1 { raw_value }, RegisterAddress::AnalogMux => Register::AnalogMux { raw_value }, RegisterAddress::IoDriverStrength => { // Parse driver strength from raw value @@ -618,7 +680,12 @@ impl Register { } Register::IoDriverStrength(IoDriverStrength { strengths }) } + RegisterAddress::ClockOrderStatus => Register::ClockOrderStatus { raw_value }, RegisterAddress::Pll3Parameter => Register::Pll3Parameter { raw_value }, + RegisterAddress::FrequencySweepControl => Register::FrequencySweepControl { raw_value }, + RegisterAddress::GoldenNonceForSweepReturn => { + Register::GoldenNonceForSweepReturn { raw_value } + } RegisterAddress::VersionMask => { let mask = (raw_value >> 16) as u16; let control = (raw_value & 0xffff) as u16; @@ -640,9 +707,16 @@ impl Register { Register::UartBaud(_) => RegisterAddress::UartBaud, Register::UartRelay { .. } => RegisterAddress::UartRelay, Register::Core { .. } => RegisterAddress::Core, + Register::ClockOrderControl0 { .. } => RegisterAddress::ClockOrderControl0, + Register::ClockOrderControl1 { .. } => RegisterAddress::ClockOrderControl1, Register::AnalogMux { .. } => RegisterAddress::AnalogMux, Register::IoDriverStrength(_) => RegisterAddress::IoDriverStrength, + Register::ClockOrderStatus { .. } => RegisterAddress::ClockOrderStatus, Register::Pll3Parameter { .. } => RegisterAddress::Pll3Parameter, + Register::FrequencySweepControl { .. } => RegisterAddress::FrequencySweepControl, + Register::GoldenNonceForSweepReturn { .. } => { + RegisterAddress::GoldenNonceForSweepReturn + } Register::VersionMask(_) => RegisterAddress::VersionMask, Register::InitControl { .. } => RegisterAddress::InitControl, Register::MiscSettings { .. } => RegisterAddress::MiscSettings, @@ -683,8 +757,13 @@ impl Register { } Register::MiscControl { raw_value } | Register::UartRelay { raw_value } + | Register::ClockOrderControl0 { raw_value } + | Register::ClockOrderControl1 { raw_value } | Register::AnalogMux { raw_value } + | Register::ClockOrderStatus { raw_value } | Register::Pll3Parameter { raw_value } + | Register::FrequencySweepControl { raw_value } + | Register::GoldenNonceForSweepReturn { raw_value } | Register::InitControl { raw_value } | Register::MiscSettings { raw_value } => { dst.put_u32_le(*raw_value); @@ -724,16 +803,26 @@ impl std::fmt::Debug for Register { Register::VersionMask(mask) => f.debug_tuple("VersionMask").field(mask).finish(), Register::MiscControl { raw_value } | Register::UartRelay { raw_value } + | Register::ClockOrderControl0 { raw_value } + | Register::ClockOrderControl1 { raw_value } | Register::AnalogMux { raw_value } + | Register::ClockOrderStatus { raw_value } | Register::Pll3Parameter { raw_value } + | Register::FrequencySweepControl { raw_value } + | Register::GoldenNonceForSweepReturn { raw_value } | Register::InitControl { raw_value } | Register::Core { raw_value } | Register::MiscSettings { raw_value } => { let register_name = match self { Register::MiscControl { .. } => "MiscControl", Register::UartRelay { .. } => "UartRelay", + Register::ClockOrderControl0 { .. } => "ClockOrderControl0", + Register::ClockOrderControl1 { .. } => "ClockOrderControl1", Register::AnalogMux { .. } => "AnalogMux", + Register::ClockOrderStatus { .. } => "ClockOrderStatus", Register::Pll3Parameter { .. } => "Pll3Parameter", + Register::FrequencySweepControl { .. } => "FrequencySweepControl", + Register::GoldenNonceForSweepReturn { .. } => "GoldenNonceForSweepReturn", Register::InitControl { .. } => "InitControl", Register::Core { .. } => "Core", Register::MiscSettings { .. } => "MiscSettings", @@ -2690,6 +2779,12 @@ impl BM13xxProtocol { RegisterAddress::UartBaud => Register::UartBaud(BaudRate::Custom(value)), RegisterAddress::UartRelay => Register::UartRelay { raw_value: value }, RegisterAddress::Core => Register::Core { raw_value: value }, + RegisterAddress::ClockOrderControl0 => { + Register::ClockOrderControl0 { raw_value: value } + } + RegisterAddress::ClockOrderControl1 => { + Register::ClockOrderControl1 { raw_value: value } + } RegisterAddress::AnalogMux => Register::AnalogMux { raw_value: value }, RegisterAddress::IoDriverStrength => { let mut strengths = [0u8; 8]; @@ -2698,7 +2793,14 @@ impl BM13xxProtocol { } Register::IoDriverStrength(IoDriverStrength { strengths }) } + RegisterAddress::ClockOrderStatus => Register::ClockOrderStatus { raw_value: value }, RegisterAddress::Pll3Parameter => Register::Pll3Parameter { raw_value: value }, + RegisterAddress::FrequencySweepControl => { + Register::FrequencySweepControl { raw_value: value } + } + RegisterAddress::GoldenNonceForSweepReturn => { + Register::GoldenNonceForSweepReturn { raw_value: value } + } RegisterAddress::VersionMask => { let mask = (value >> 16) as u16; let control = (value & 0xffff) as u16; diff --git a/mujina-miner/src/asic/bm13xx/sequencer.rs b/mujina-miner/src/asic/bm13xx/sequencer.rs index 66679469..d8fe16f1 100644 --- a/mujina-miner/src/asic/bm13xx/sequencer.rs +++ b/mujina-miner/src/asic/bm13xx/sequencer.rs @@ -189,6 +189,12 @@ impl Sequencer { pub fn build_frequency_ramp(&self, target: Frequency) -> Vec<(Frequency, Step)> { const INITIAL_FREQ_MHZ: f32 = 56.25; const STEP_MHZ: f32 = 6.25; + // 100 ms gives chip PLLs roughly 10 lock periods between steps, + // empirically enough to land at the target rate without dropping + // chips. LuxOS spaces steps ~4 s apart on BHB56902 but that's + // tied to its readback-and-verify pattern (`4205 08` reads + // between every write); we don't do those reads so a faster + // cadence is fine. const STEP_DELAY: Duration = Duration::from_millis(100); let mut steps = vec![]; @@ -272,24 +278,26 @@ impl Sequencer { )); } - // InitControl (0xA8) = 0x00 broadcast - prepare chips for enumeration - // emberone-miner does this before ChainInactive. The value 0x02 comes later - // in per-chip configuration. + // InitControl (0xA8) broadcast - prepare chips for enumeration. + // The exact value is chip-family specific (BM1362 = 0, BM1366 = + // 0x0000_0700) and lives on the chip config so each family's + // init bytes match ESP-Miner exactly. steps.push(Step::new(Command::WriteRegister { broadcast: true, chip_address: 0x00, register: Register::InitControl { - raw_value: 0x0000_0000, + raw_value: self.chip_config.init_regs.init_control_broadcast, }, })); - // MiscControl (0x18) broadcast - enables clock and core functionality - // Wire bytes: B0 00 C1 00 + // MiscControl (0x18) broadcast - enables clock and core + // functionality. Chip-family specific (BM1362 wire bytes + // `B0 00 C1 00`, BM1366 `FF 0F C1 00`). steps.push(Step::new(Command::WriteRegister { broadcast: true, chip_address: 0x00, register: Register::MiscControl { - raw_value: 0x00C1_00B0, + raw_value: self.chip_config.init_regs.misc_control_broadcast, }, })); @@ -331,24 +339,19 @@ impl Sequencer { fn default_reg_config(&self, chain: &Chain) -> Vec { let mut steps = vec![]; + let init = &self.chip_config.init_regs; // Phase 1: Broadcast configuration - // Core configuration - first two writes are broadcast - // BM1362 values from emberone-miner reference - steps.push(Step::new(Command::WriteRegister { - broadcast: true, - chip_address: 0x00, - register: Register::Core { - raw_value: 0x8000_8540, - }, - })); - steps.push(Step::new(Command::WriteRegister { - broadcast: true, - chip_address: 0x00, - register: Register::Core { - raw_value: 0x8000_8008, - }, - })); + // Core (0x3C) broadcasts. Values are chip-family specific: + // BM1362 wire bytes `40 85 00 80` + `08 80 00 80`; BM1366 + // `80 00 85 40` + `80 00 80 20`. + for raw_value in init.core_broadcast { + steps.push(Step::new(Command::WriteRegister { + broadcast: true, + chip_address: 0x00, + register: Register::Core { raw_value }, + })); + } // TicketMask controls which nonces chips report. // Scale hashrate by chip count to maintain ~1 nonce/sec across the chain. @@ -364,21 +367,27 @@ impl Sequencer { register: Register::TicketMask(TicketMask::new(reporting_interval)), })); - // AnalogMux (0x54) configuration from emberone-miner - // Wire bytes: 00 00 00 03 + // AnalogMux (0x54) broadcast. Same wire bytes across BM1362 + // and BM1366 (`00 00 00 03`) per ESP-Miner. steps.push(Step::new(Command::WriteRegister { broadcast: true, chip_address: 0x00, register: Register::AnalogMux { - raw_value: 0x0300_0000, + raw_value: init.analog_mux_broadcast, }, })); - // Broadcast IO driver strength to all chips + // IO driver strength broadcast. BM1366 overrides the per-chain + // default with an exact wire pattern (`02 11 11 11`); other + // chips fall back to chip_config.io_driver. + let io_driver = init + .io_driver_broadcast_raw + .map(IoDriverStrength::from_raw_le_u32) + .unwrap_or(self.chip_config.io_driver); steps.push(Step::new(Command::WriteRegister { broadcast: true, chip_address: 0x00, - register: Register::IoDriverStrength(self.chip_config.io_driver), + register: Register::IoDriverStrength(io_driver), })); // Basic PLL configuration at ~56 MHz (emberone-miner's starting frequency) @@ -391,56 +400,74 @@ impl Sequencer { register: Register::PllDivider(PllConfig::new(0xDD, 2, 0x66)), })); - // Phase 2: Per-chip configuration with delays - // InitControl, MiscControl, and Core x3 per-chip with brief delay - // to allow cores to stabilize before moving to next chip. + // Phase 2: Per-chip configuration with delays. + // + // BM1366 prepends UartRelay (0x2C) writes that BM1362 skips. Two + // modes are supported (see `chip_config::ChainInitRegs`): + // - `uart_relay_perdomain`: write to first + last chip of each + // domain with a per-domain value (BHB56902 / S19k Pro). + // - `uart_relay_perchip`: write the same value to every chip in + // the chain (Bitaxe Supra single-domain). + // The rest of the sequence (InitControl, MiscControl, Core × 3) is + // shared in shape but uses chip-family raw bytes. + if let Some(spec) = init.uart_relay_perdomain { + for (host_dist, domain) in chain.domains_far_to_near().enumerate() { + let domain_idx = chain.domain_count().saturating_sub(host_dist + 1); + let raw_value = spec.value_for(domain_idx); + for &chip_id in &[ + chain.domain_first(domain.id), + chain.domain_last(domain.id), + ] { + let chip_address = chain.chip(chip_id).address; + steps.push(Step::new(Command::WriteRegister { + broadcast: false, + chip_address, + register: Register::UartRelay { raw_value }, + })); + } + } + } + for (_, chip) in chain.chips() { - // InitControl (0xA8) = 0x02 per-chip - // Wire bytes: 00 00 00 02 + if let Some(raw_value) = init.uart_relay_perchip { + steps.push(Step::new(Command::WriteRegister { + broadcast: false, + chip_address: chip.address, + register: Register::UartRelay { raw_value }, + })); + } + steps.push(Step::new(Command::WriteRegister { broadcast: false, chip_address: chip.address, register: Register::InitControl { - raw_value: 0x0200_0000, + raw_value: init.init_control_perchip, }, })); - // MiscControl (0x18) per-chip - // Wire bytes: B0 00 C1 00 steps.push(Step::new(Command::WriteRegister { broadcast: false, chip_address: chip.address, register: Register::MiscControl { - raw_value: 0x00C1_00B0, + raw_value: init.misc_control_perchip, }, })); - // Core (0x3C) x3 per-chip - enables hashing cores - steps.push(Step::new(Command::WriteRegister { - broadcast: false, - chip_address: chip.address, - register: Register::Core { - raw_value: 0x8000_8540, - }, - })); - steps.push(Step::new(Command::WriteRegister { - broadcast: false, - chip_address: chip.address, - register: Register::Core { - raw_value: 0x8000_8008, - }, - })); - // Third Core write - critical for enabling actual hashing - steps.push(Step::with_delay( - Command::WriteRegister { + // Core (0x3C) × 3 per-chip — enables hashing cores. The + // last write gets a small post-delay so cores stabilise + // before the next chip's writes start. + for (i, raw_value) in init.core_perchip.iter().copied().enumerate() { + let cmd = Command::WriteRegister { broadcast: false, chip_address: chip.address, - register: Register::Core { - raw_value: 0x8000_82AA, - }, - }, - Duration::from_millis(50), // Brief delay for core stabilization - )); + register: Register::Core { raw_value }, + }; + if i + 1 == init.core_perchip.len() { + steps.push(Step::with_delay(cmd, Duration::from_millis(50))); + } else { + steps.push(Step::new(cmd)); + } + } } // Phase 3: Final broadcast configuration @@ -469,22 +496,16 @@ impl Sequencer { /// AnalogMux, IoDriverStrength, and initial PLL at ~56 MHz. fn default_reg_config_broadcast(&self, chain: &Chain) -> Vec { let mut steps = vec![]; + let init = &self.chip_config.init_regs; - // Core configuration - first two writes are broadcast - steps.push(Step::new(Command::WriteRegister { - broadcast: true, - chip_address: 0x00, - register: Register::Core { - raw_value: 0x8000_8540, - }, - })); - steps.push(Step::new(Command::WriteRegister { - broadcast: true, - chip_address: 0x00, - register: Register::Core { - raw_value: 0x8000_8008, - }, - })); + // Core (0x3C) broadcast writes. Chip-family specific values. + for raw_value in init.core_broadcast { + steps.push(Step::new(Command::WriteRegister { + broadcast: true, + chip_address: 0x00, + register: Register::Core { raw_value }, + })); + } // TicketMask controls which nonces chips report. // Scale hashrate by chip count to maintain ~1 nonce/sec across the chain. @@ -500,22 +521,52 @@ impl Sequencer { register: Register::TicketMask(TicketMask::new(reporting_interval)), })); - // AnalogMux (0x54) + // AnalogMux (0x54) broadcast. steps.push(Step::new(Command::WriteRegister { broadcast: true, chip_address: 0x00, register: Register::AnalogMux { - raw_value: 0x0300_0000, + raw_value: init.analog_mux_broadcast, }, })); - // IO driver strength + // IO driver strength. BM1366 overrides with an exact raw + // pattern (`02 11 11 11`); other chips take the per-chain + // default. + let io_driver = init + .io_driver_broadcast_raw + .map(IoDriverStrength::from_raw_le_u32) + .unwrap_or(self.chip_config.io_driver); steps.push(Step::new(Command::WriteRegister { broadcast: true, chip_address: 0x00, - register: Register::IoDriverStrength(self.chip_config.io_driver), + register: Register::IoDriverStrength(io_driver), })); + // Per-domain-boundary UartRelay (reg 0x2C). MUST go out here, at + // the slow baud, before the post-broadcast UART speed bump fires: + // these registers tune the chain's relay timing budget for the + // current bit rate. LuxOS sends them between IoDriverStrength and + // the UartBaud broadcast — anything that lands at the new (fast) + // baud drops chips downstream of the affected domain. + if let Some(spec) = init.uart_relay_perdomain { + for (host_dist, domain) in chain.domains_far_to_near().enumerate() { + let domain_idx = chain.domain_count().saturating_sub(host_dist + 1); + let raw_value = spec.value_for(domain_idx); + for &chip_id in &[ + chain.domain_first(domain.id), + chain.domain_last(domain.id), + ] { + let chip_address = chain.chip(chip_id).address; + steps.push(Step::new(Command::WriteRegister { + broadcast: false, + chip_address, + register: Register::UartRelay { raw_value }, + })); + } + } + } + // NOTE: No PLL write here - emberone-miner sets initial PLL at start // of frequency ramp, not in broadcast config. @@ -529,52 +580,55 @@ impl Sequencer { /// frequency. fn default_reg_config_perchip(&self, chain: &Chain) -> Vec { let mut steps = vec![]; + let init = &self.chip_config.init_regs; + + // NOTE: Per-domain-boundary UartRelay used to live here, but it + // must run BEFORE the post-broadcast UART baud bump. It now lives + // in `default_reg_config_broadcast` so the relay-timing registers + // are written at the slow baud where the chain is still tolerant. for (_, chip) in chain.chips() { - // InitControl (0xA8) = 0x02 per-chip + // BM1366 PerChip (Bitaxe Supra single-domain etc.). On boards + // using the per-domain-boundary path above, this is None. + if let Some(raw_value) = init.uart_relay_perchip { + steps.push(Step::new(Command::WriteRegister { + broadcast: false, + chip_address: chip.address, + register: Register::UartRelay { raw_value }, + })); + } + steps.push(Step::new(Command::WriteRegister { broadcast: false, chip_address: chip.address, register: Register::InitControl { - raw_value: 0x0200_0000, + raw_value: init.init_control_perchip, }, })); - // MiscControl (0x18) per-chip steps.push(Step::new(Command::WriteRegister { broadcast: false, chip_address: chip.address, register: Register::MiscControl { - raw_value: 0x00C1_00B0, + raw_value: init.misc_control_perchip, }, })); - // Core (0x3C) x3 per-chip - enables hashing cores - steps.push(Step::new(Command::WriteRegister { - broadcast: false, - chip_address: chip.address, - register: Register::Core { - raw_value: 0x8000_8540, - }, - })); - steps.push(Step::new(Command::WriteRegister { - broadcast: false, - chip_address: chip.address, - register: Register::Core { - raw_value: 0x8000_8008, - }, - })); - // Third Core write - critical for enabling actual hashing - steps.push(Step::with_delay( - Command::WriteRegister { + // Core (0x3C) × N per-chip — enables hashing cores. The + // final write gets a small post-delay so cores stabilise + // before the next chip's writes start. + for (i, raw_value) in init.core_perchip.iter().copied().enumerate() { + let cmd = Command::WriteRegister { broadcast: false, chip_address: chip.address, - register: Register::Core { - raw_value: 0x8000_82AA, - }, - }, - Duration::from_millis(50), - )); + register: Register::Core { raw_value }, + }; + if i + 1 == init.core_perchip.len() { + steps.push(Step::with_delay(cmd, Duration::from_millis(50))); + } else { + steps.push(Step::new(cmd)); + } + } } // Final broadcast: NonceRange and VersionMask @@ -592,6 +646,26 @@ impl Sequencer { register: Register::VersionMask(VersionMask::full_rolling()), })); + // Post-per-chip TicketMask broadcast. LuxOS on BHB56902 issues + // this AFTER the per-chip writes (`captures/luxos-bhb56902-full-mining.log` + // shows broadcast `reg 0x14 val=0x000000c0` at t=+10.1s, between + // the post-per-chip NonceRange/PllDivider broadcasts and the + // start of the frequency ramp). Wire byte `0xC0` = `zero_bits=2` + // in our encoding (very low filter = chip ticks every nonce + // with 2+ leading zero bits up to the controller). The default + // `default_reg_config_broadcast` path runs BEFORE per-chip and + // uses a dynamically scaled mask suited for older small-chain + // boards — on BHB56902 we want to override it back to 0xC0 so + // the share rate is high enough for the LuxOS-equivalent hashrate + // estimator to converge. + if let Some(zero_bits) = self.chip_config.post_perchip_ticket_zero_bits { + steps.push(Step::new(Command::WriteRegister { + broadcast: true, + chip_address: 0x00, + register: Register::TicketMask(TicketMask::from_zero_bits(zero_bits)), + })); + } + steps } } diff --git a/mujina-miner/src/asic/bm13xx/thread_v2.rs b/mujina-miner/src/asic/bm13xx/thread_v2.rs index 2190ebfc..e49682e0 100644 --- a/mujina-miner/src/asic/bm13xx/thread_v2.rs +++ b/mujina-miner/src/asic/bm13xx/thread_v2.rs @@ -11,6 +11,7 @@ //! are passed to the constructor separately from config. A future design might //! use a factory or allow stream replacement. +use std::pin::Pin; use std::sync::Arc; use std::time::Duration; @@ -77,11 +78,24 @@ impl BM13xxThread { /// Returns error if `config.peripherals.asic_enable` is missing. pub fn new(chip_rx: R, chip_tx: W, config: ChainConfig) -> Result where - R: Stream> + Unpin + Send + 'static, - W: Sink + Unpin + Send + 'static, - W::Error: std::fmt::Debug, + R: Stream> + Send + 'static, + W: Sink + Send + 'static, { - // Build chain model from topology + let chip_rx: Pin< + Box> + Send + 'static>, + > = Box::pin(chip_rx); + let chip_tx: Pin< + Box + Send + 'static>, + > = Box::pin(chip_tx); + // Build chain model from topology. + // + // Address interval=2 outperforms the ESP-Miner BM1366 default + // of 256/chip_count (=3 for 77 chips): live S19k Pro tests + // showed interval=2 enumerates 34/77 chips while interval=3 + // enumerates only 22/77, both with the BM1366 register + // decoder fully patched. The reason isn't clear yet — likely + // a bus-integrity quirk on this hashboard layout — so retain + // interval=2 as the empirically-better default. let mut chain = Chain::from_topology(&config.topology); chain.assign_addresses().map_err(|e| { HashThreadError::InitializationFailed(format!("Address assignment failed: {}", e)) @@ -109,7 +123,8 @@ impl BM13xxThread { // Spawn reader task - continuously reads from serial to prevent USB // CDC-ACM flow control from blocking TX. Runs until chip_rx closes. - tokio::spawn(serial_reader_task(chip_rx, response_tx)); + let reader_handle = + tokio::spawn(serial_reader_task(chip_rx, response_tx.clone())); // Spawn actor - it will initialize chips lazily on first work assignment tokio::spawn(async move { @@ -120,7 +135,10 @@ impl BM13xxThread { estimated_hashrate: initial_hashrate, response_rx, chip_tx, + reader_handle: Some(reader_handle), + response_tx, peripherals: config.peripherals, + post_broadcast_chip_baud: config.post_broadcast_chip_baud, chain, sequencer, chip_state: ChipState::Disabled, @@ -148,12 +166,12 @@ impl BM13xxThread { /// Without a continuous reader, TX blocks after ~7-8 commands. This task /// ensures RX is always being serviced, forwarding decoded responses to /// the actor via channel. -async fn serial_reader_task( - mut chip_rx: R, +async fn serial_reader_task( + mut chip_rx: Pin< + Box> + Send + 'static>, + >, response_tx: mpsc::Sender>, -) where - R: Stream> + Unpin, -{ +) { while let Some(response) = chip_rx.next().await { if response_tx.send(response).await.is_err() { // Actor dropped, exit reader @@ -245,15 +263,26 @@ impl std::fmt::Debug for BM13xxThread { /// Serial RX is handled by a separate reader task that forwards decoded /// responses via `response_rx`. This ensures continuous reading from the /// serial port, which is required for USB CDC-ACM flow control. -struct BM13xxActor { +struct BM13xxActor { cmd_rx: mpsc::Receiver, evt_tx: mpsc::Sender, status: Arc>, estimated_hashrate: HashRate, /// Responses from chips, forwarded by the reader task. response_rx: mpsc::Receiver>, - chip_tx: W, + chip_tx: Pin + Send + 'static>>, + /// Handle on the currently-running serial reader task. Replaced + /// when the actor swaps the underlying serial stream (baud bump). + reader_handle: Option>, + /// Sender used by the reader task to forward decoded responses to + /// `response_rx`. Retained so we can re-spawn a fresh reader task + /// after a baud-rate swap. + response_tx: mpsc::Sender>, peripherals: super::chain_config::ChainPeripherals, + /// If set, after the broadcast-phase init the actor sends a UartBaud + /// broadcast and switches the controller-side serial to this rate + /// before running the per-chip phase. See `ChainConfig`. + post_broadcast_chip_baud: Option, /// Chain model with chip addresses and domain structure. chain: Chain, @@ -371,9 +400,13 @@ fn voltage_for_frequency_stacked( /// /// When polling each chip individually, this is how long to wait for a single /// response before declaring the chip unresponsive. At 115200 baud, an 11-byte -/// response takes ~1ms on the wire; 100ms is generous headroom for USB -/// buffering and chip processing. -const PER_CHIP_TIMEOUT: Duration = Duration::from_millis(100); +/// response takes ~1ms on the wire; 500ms is generous headroom for USB +/// buffering, chip processing, and post-baud-bump nonce traffic that the +/// verify drains-and-ignores between each addressed read. On BHB56902 at +/// 3.125 Mbaud, 100 ms reported 10–61 chips "missing" even though chain +/// hashrate showed all 77 contributing; bumping the cap reduces the +/// false-negative rate. +const PER_CHIP_TIMEOUT: Duration = Duration::from_millis(500); /// Convert HashTask to JobFullFormat for chip hardware. /// @@ -419,11 +452,7 @@ fn task_to_job_full( }) } -impl BM13xxActor -where - W: Sink + Unpin, - W::Error: std::fmt::Debug, -{ +impl BM13xxActor { fn update_status(&self, mutate: impl FnOnce(&mut HashThreadStatus)) -> HashThreadStatus { let mut status = self.status.write(); mutate(&mut status); @@ -658,12 +687,25 @@ where // 5. Execute broadcast register configuration (Phase 1) self.execute_reg_config_broadcast().await?; + // 5b. Optionally bump the chip↔controller UART baud rate before + // the per-chip writes. This matches LuxOS on BHB56902, which + // switches the chain to 3 Mbaud right after the broadcasts (115200 + // is the throughput bottleneck on chains with ~10+ domains). + self.maybe_switch_chip_baud().await?; + // 6. Execute per-chip register configuration (Phase 2) // Enables cores before frequency ramp (experimenting with order). self.execute_reg_config_perchip().await?; - // 7. Ramp frequency to target (flat voltage throughout) - self.execute_frequency_ramp(protocol::Frequency::from_mhz(500.0)) + // 7. Ramp frequency to target. Per-chip-family target lives on + // `ChipConfig::target_frequency_mhz`; falls back to 500 MHz + // for older configurations that haven't been profiled. + let target_mhz = self + .sequencer + .chip_config() + .target_frequency_mhz + .unwrap_or(500.0); + self.execute_frequency_ramp(protocol::Frequency::from_mhz(target_mhz)) .await?; self.chip_state = ChipState::Initialized; @@ -732,6 +774,142 @@ where .await } + /// If [`ChainConfig::post_broadcast_chip_baud`] is set, send the + /// chip-side `UartBaud` broadcast, then ask the board to close the + /// current `/dev/ttyS2` handle and re-open it at the new rate. The + /// reader task is torn down and respawned around the new stream so + /// per-chip writes and steady-state work both flow at the new rate. + async fn maybe_switch_chip_baud(&mut self) -> Result<(), HashThreadError> { + let Some(target_baud) = self.post_broadcast_chip_baud else { + return Ok(()); + }; + let Some(chip_uart) = self.peripherals.chip_uart_baud.as_ref() else { + warn!( + target_baud = ?target_baud, + "post_broadcast_chip_baud configured but peripherals.chip_uart_baud is None; skipping" + ); + return Ok(()); + }; + + let numeric_baud = match target_baud { + protocol::BaudRate::Baud115200 => 115_200, + protocol::BaudRate::Baud1M => 1_000_000, + // 3,125,000 — NOT 3,000,000. The chip-side register value + // `0x00003011` (wire bytes `11 30 00 00`) produces an + // actual 3.125 Mbaud, not 3.0 Mbaud (Braiins log: + // `Set baud rate @ requested: 3125000, actual: 3125000`, + // and the LuxOS sniff in + // `captures/luxos-bhb56902-steady-state.log` opens the new + // fd at 3.125 Mbaud after broadcasting the same wire + // value). With mujina commanding 3,000,000 the controller + // and chip-side rates were ~4% apart, which is enough to + // mangle frames on long chains and drop chips. 3,125,000 + // matches both ends exactly. + protocol::BaudRate::Baud3M => 3_125_000, + protocol::BaudRate::Custom(_) => { + warn!("Custom UartBaud values can't be auto-mapped to a controller baud; skipping"); + return Ok(()); + } + }; + + info!(target_baud_hz = numeric_baud, "Bumping chip UART baud"); + + // Two-phase LuxOS-matched baud switch: + // + // From `captures/luxos-bhb56902-full-mining.log`: + // t=508.025 OPEN64 fd=23 (open new fd ~2 s before broadcast) + // t=510.127 TX fd=23 UartBaud broadcast (still at 115200) + // t=510.340 OPEN64 fd=25 (213 ms after broadcast) + // t=510.4+ per-chip writes on the new fd at 3 Mbaud + // + // The key is that the new fd is open BEFORE the broadcast, but + // not retuned to the target baud — both fds stay at 115200 while + // the broadcast lands on chips. Only AFTER the broadcast does + // the active fd get tcsetattr'd to the higher rate. + + // 1. Pre-open the new fd at the CURRENT baud (115200). The + // kernel tcsetattr inside `SerialStream::new` is a no-op for + // the device since it's already at 115200, so the in-flight + // broadcast can still complete cleanly. /dev/ttyS2 now has + // two fds open through the rest of the switch. + const CURRENT_BAUD_HZ: u32 = 115_200; + let (new_rx, new_tx) = { + let mut control = chip_uart.lock().await; + control + .prepare_new_stream(CURRENT_BAUD_HZ) + .await + .map_err(|e| { + HashThreadError::InitializationFailed(format!( + "Failed to pre-open chip UART at {CURRENT_BAUD_HZ}: {e:?}" + )) + })? + }; + + // 2. Broadcast UartBaud on the OLD writer. Kernel termios is + // still 115200, so the bytes leave the wire at 115200 and + // chips can decode them and apply their internal baud + // change. + self.chip_tx + .send(protocol::Command::WriteRegister { + broadcast: true, + chip_address: 0x00, + register: protocol::Register::UartBaud(target_baud), + }) + .await + .map_err(|e| { + HashThreadError::InitializationFailed(format!( + "Failed to send UartBaud broadcast: {e:?}" + )) + })?; + self.chip_tx.flush().await.ok(); + + // 3. Wait for chips to apply their internal baud change. LuxOS + // sees 213 ms broadcast → next OPEN64 in capture; with our + // timing at 213 ms we got 44/77 chips, at 213 ms with longer + // post-settle we got 67/77. Lean longer here to recover the + // far-end chips: their relay timing is more marginal. + time::sleep(Duration::from_millis(500)).await; + + // 4. Retune the new fd's termios to the target baud rate. + // Because termios is shared across fds on the same device, + // this also flips the OLD fd's effective hw baud to 3 M — + // fine, since we're done using it. + { + let mut control = chip_uart.lock().await; + control + .finalize_baud_switch(numeric_baud) + .await + .map_err(|e| { + HashThreadError::InitializationFailed(format!( + "Failed to finalize baud switch to {numeric_baud}: {e:?}" + )) + })?; + } + + // 5. Swap the actor's chip channels onto the new fd. The OLD + // writer is dropped here, and we drain the reader task + // waiting for it to observe its stream ending. The kernel + // still has the new fd open, so the device never goes + // unclaimed. + let stale_reader = self.reader_handle.take(); + let _old_tx = std::mem::replace(&mut self.chip_tx, new_tx); + drop(_old_tx); + if let Some(handle) = stale_reader { + let _ = tokio::time::timeout(Duration::from_millis(200), handle).await; + } + self.reader_handle = Some(tokio::spawn(serial_reader_task( + new_rx, + self.response_tx.clone(), + ))); + + // 6. Settle before the per-chip writes start. Pad generously — + // the cost of waiting an extra 100-200 ms on init is small + // versus losing a chip out of the chain. + time::sleep(Duration::from_millis(250)).await; + + Ok(()) + } + /// Execute coordinated voltage-frequency ramp to target. /// /// At each step, the voltage regulator is adjusted first (leading the @@ -860,62 +1038,90 @@ where let expected_count = self.chain.chip_count(); let addresses: Vec = self.chain.chips().map(|(_, chip)| chip.address).collect(); - let mut responding_count: usize = 0; - - for &addr in &addresses { - // Drain any stale responses before each query - while self.response_rx.try_recv().is_ok() {} - - if let Err(e) = self - .chip_tx - .send(Command::ReadRegister { - broadcast: false, - chip_address: addr, - register_address: RegisterAddress::ChipId, - }) - .await - { - error!(error = ?e, "Failed to send verification query"); - return responding_count; - } + // Multi-pass: poll all chips, then re-poll the missing ones up to + // two more times. On BHB56902 at 3.125 Mbaud the first sweep + // consistently reports the first ~10 chips (addresses 0x00..0x12, + // closest to host) as missing even though chain hashrate math + // shows them contributing — likely chain-traffic shadowing the + // first sweep, not dead chips. + const VERIFY_PASSES: usize = 3; + + let mut to_poll: Vec = addresses; + let mut alive: Vec = Vec::new(); + + for pass in 0..VERIFY_PASSES { + let mut missing_this_pass: Vec = Vec::new(); + + for &addr in &to_poll { + // Drain any stale responses before each query + while self.response_rx.try_recv().is_ok() {} + + if let Err(e) = self + .chip_tx + .send(Command::ReadRegister { + broadcast: false, + chip_address: addr, + register_address: RegisterAddress::ChipId, + }) + .await + { + error!(error = ?e, "Failed to send verification query"); + return alive.len(); + } - // Wait for a single response - let got_response = loop { - tokio::select! { - response = self.response_rx.recv() => { - match response { - Some(Ok(protocol::Response::ReadRegister { - register: Register::ChipId { .. }, - .. - })) => break true, - Some(Ok(_)) | Some(Err(_)) => { - // Ignore non-ChipId responses and errors, - // keep waiting for the real response. - continue; + let got_response = loop { + tokio::select! { + response = self.response_rx.recv() => { + match response { + Some(Ok(protocol::Response::ReadRegister { + register: Register::ChipId { .. }, + .. + })) => break true, + Some(Ok(_)) | Some(Err(_)) => continue, + None => break false, } - None => break false, } + _ = time::sleep(PER_CHIP_TIMEOUT) => break false, } - _ = time::sleep(PER_CHIP_TIMEOUT) => { - break false; - } + }; + + if got_response { + alive.push(addr); + } else { + missing_this_pass.push(addr); } - }; + } + + debug!( + pass = pass + 1, + total_passes = VERIFY_PASSES, + alive = alive.len(), + still_missing = missing_this_pass.len(), + "Verify pass complete" + ); - if got_response { - responding_count += 1; + if missing_this_pass.is_empty() { + break; } + to_poll = missing_this_pass; } - // Log result + let responding_count = alive.len(); if responding_count == expected_count { debug!(count = responding_count, "Chain verification passed"); } else { + let missing_str = to_poll + .iter() + .map(|a| format!("0x{:02x}", a)) + .collect::>() + .join(","); error!( expected = expected_count, responding = responding_count, missing = expected_count.saturating_sub(responding_count), - "Chain verification failed: not all chips responded" + missing_addrs = missing_str, + passes = VERIFY_PASSES, + "Chain verification failed: not all chips responded after retries" ); } @@ -967,6 +1173,15 @@ where // HACK: BM1362 job_id fix - protocol.rs extracts job_id from bits 7-4, // but BM1362 returns it in bits 6-3. Reconstruct result_header and re-extract. // TODO: Move this to protocol.rs with chip-type-aware parsing. + // + // Empirically required on BM1366 too: gating this to BM1362 + // only collapsed BM1366 mining hashrate from ~29 TH/s to + // ~3 TH/s on a BHB56902. The BM1366 nonce response uses + // the BM1362-style 5-bit-job-id / 3-bit-subcore packing, + // NOT the standard 4+4 layout that the protocol decoder + // assumes. Until we move chip-type-aware parsing into + // protocol.rs, the universal fix-up here is what makes + // BM1366 work. let result_header = (job_id << 4) | subcore_id; let job_id = (result_header >> 3) & 0x0f; @@ -1161,6 +1376,17 @@ mod tests { } } + /// Drain-style sink with the `Error = std::io::Error` bound the + /// production `ChipTxSink` requires. Tests previously passed + /// `futures::sink::drain()` directly, but that has + /// `Error = std::convert::Infallible`, so it no longer matches. + fn drain_sink() -> impl Sink + Send + 'static { + use futures::sink::SinkExt; + futures::sink::drain().sink_map_err(|_| { + std::io::Error::new(std::io::ErrorKind::Other, "drain") + }) + } + /// Sink that fails on first send---used to test send error handling. struct FailingSink; @@ -1246,7 +1472,9 @@ mod tests { peripherals: ChainPeripherals { asic_enable: Arc::new(Mutex::new(asic_enable)), voltage_regulator: None, + chip_uart_baud: None, }, + post_broadcast_chip_baud: None, } } @@ -1259,10 +1487,9 @@ mod tests { chain: Chain, sequencer: Sequencer, asic_enable: MockAsicEnable, - ) -> BM13xxActor + ) -> BM13xxActor where - W: Sink + Unpin, - W::Error: std::fmt::Debug, + W: Sink + Send + 'static, { let (_cmd_tx, cmd_rx) = mpsc::channel(10); let (evt_tx, _evt_rx) = mpsc::channel(100); @@ -1280,11 +1507,15 @@ mod tests { status, estimated_hashrate: HashRate::from_gigahashes(83.0 * chain.chip_count() as f64), response_rx, - chip_tx, + chip_tx: Box::pin(chip_tx), + reader_handle: None, + response_tx, peripherals: ChainPeripherals { asic_enable: Arc::new(Mutex::new(asic_enable)), voltage_regulator: None, + chip_uart_baud: None, }, + post_broadcast_chip_baud: None, chain, sequencer, chip_state: ChipState::Disabled, @@ -1306,7 +1537,7 @@ mod tests { async fn construction_succeeds() { let responses: Vec> = vec![]; let chip_rx = stream::iter(responses); - let chip_tx = futures::sink::drain(); + let chip_tx = drain_sink(); let config = test_config(1, MockAsicEnable::new()); let result = BM13xxThread::new(chip_rx, chip_tx, config); @@ -1320,7 +1551,7 @@ mod tests { async fn go_idle_on_idle_thread_returns_none() { let responses: Vec> = vec![]; let chip_rx = stream::iter(responses); - let chip_tx = futures::sink::drain(); + let chip_tx = drain_sink(); let config = test_config(1, MockAsicEnable::new()); let mut thread = BM13xxThread::new(chip_rx, chip_tx, config).unwrap(); @@ -1334,7 +1565,7 @@ mod tests { async fn actor_exits_when_facade_dropped() { let responses: Vec> = vec![]; let chip_rx = stream::iter(responses); - let chip_tx = futures::sink::drain(); + let chip_tx = drain_sink(); let config = test_config(1, MockAsicEnable::new()); let thread = BM13xxThread::new(chip_rx, chip_tx, config).unwrap(); @@ -1348,7 +1579,7 @@ mod tests { async fn initialize_single_chip_succeeds() { // Topology expects 1 chip, hardware provides 1 chip response let responses = vec![chip_id_response(ChipType::BM1362, 0x00)]; - let chip_tx = futures::sink::drain(); + let chip_tx = drain_sink(); let (chain, sequencer) = chain_and_sequencer(1); let mut actor = test_actor(responses, chip_tx, chain, sequencer, MockAsicEnable::new()); @@ -1365,7 +1596,7 @@ mod tests { let responses: Vec<_> = (0..12) .map(|i| chip_id_response(ChipType::BM1362, i * 2)) // interval 2 .collect(); - let chip_tx = futures::sink::drain(); + let chip_tx = drain_sink(); let (chain, sequencer) = chain_and_sequencer(12); let mut actor = test_actor(responses, chip_tx, chain, sequencer, MockAsicEnable::new()); @@ -1384,7 +1615,7 @@ mod tests { let responses: Vec<_> = (0..responding) .map(|i| chip_id_response(ChipType::BM1362, (i * 2) as u8)) .collect(); - let chip_tx = futures::sink::drain(); + let chip_tx = drain_sink(); let (chain, sequencer) = chain_and_sequencer(expected); let mut actor = test_actor(responses, chip_tx, chain, sequencer, MockAsicEnable::new()); @@ -1403,7 +1634,7 @@ mod tests { let responses: Vec<_> = (0..responding) .map(|i| chip_id_response(ChipType::BM1362, (i * 2) as u8)) .collect(); - let chip_tx = futures::sink::drain(); + let chip_tx = drain_sink(); let (chain, sequencer) = chain_and_sequencer(expected); let mut actor = test_actor(responses, chip_tx, chain, sequencer, MockAsicEnable::new()); @@ -1422,7 +1653,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn initialize_enable_failure_propagates() { let responses: Vec> = vec![]; - let chip_tx = futures::sink::drain(); + let chip_tx = drain_sink(); let (chain, sequencer) = chain_and_sequencer(1); let mut actor = test_actor( @@ -1590,7 +1821,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn disable_chips_noop_when_already_disabled() { let responses: Vec> = vec![]; - let chip_tx = futures::sink::drain(); + let chip_tx = drain_sink(); let (chain, sequencer) = chain_and_sequencer(1); let mut actor = test_actor(responses, chip_tx, chain, sequencer, MockAsicEnable::new()); @@ -1603,7 +1834,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn handle_go_idle_disables_and_clears_task() { let responses: Vec> = vec![]; - let chip_tx = futures::sink::drain(); + let chip_tx = drain_sink(); let (chain, sequencer) = chain_and_sequencer(1); let mut actor = test_actor(responses, chip_tx, chain, sequencer, MockAsicEnable::new()); diff --git a/mujina-miner/src/backplane.rs b/mujina-miner/src/backplane.rs index 5619689a..d7ac6b7a 100644 --- a/mujina-miner/src/backplane.rs +++ b/mujina-miner/src/backplane.rs @@ -124,8 +124,15 @@ impl Backplane { async fn handle_amlogic_event(&mut self, event: AmlogicTransportEvent) -> Result<()> { match event { AmlogicTransportEvent::DeviceConnected(device_info) => { - let Some(descriptor) = self.virtual_registry.find("s19j_pro_amlogic") else { - error!("No virtual board descriptor found for s19j_pro_amlogic"); + let descriptor_key = match device_info.model { + crate::config::HashboardModel::S19jPro => "s19j_pro_amlogic", + crate::config::HashboardModel::S19kPro => "s19k_pro_amlogic", + }; + let Some(descriptor) = self.virtual_registry.find(descriptor_key) else { + error!( + descriptor = descriptor_key, + "No virtual board descriptor found for Amlogic model" + ); return Ok(()); }; diff --git a/mujina-miner/src/board/emberone.rs b/mujina-miner/src/board/emberone.rs index 11d3bd04..b3cce422 100644 --- a/mujina-miner/src/board/emberone.rs +++ b/mujina-miner/src/board/emberone.rs @@ -383,7 +383,9 @@ impl Board for EmberOne { io_power_enable_pin, })), voltage_regulator, + chip_uart_baud: None, }, + post_broadcast_chip_baud: None, }; // Create the hash thread diff --git a/mujina-miner/src/board/mod.rs b/mujina-miner/src/board/mod.rs index 219d0c36..b441c3af 100644 --- a/mujina-miner/src/board/mod.rs +++ b/mujina-miner/src/board/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod emberone; pub mod pattern; pub(crate) mod s19j_pro_amlogic; pub(crate) mod s19j_pro_bitcrane; +pub(crate) mod s19k_pro_amlogic; use async_trait::async_trait; use std::{error::Error, fmt, future::Future, pin::Pin}; diff --git a/mujina-miner/src/board/s19j_pro_amlogic.rs b/mujina-miner/src/board/s19j_pro_amlogic.rs index bf5f16d1..a5ee81a5 100644 --- a/mujina-miner/src/board/s19j_pro_amlogic.rs +++ b/mujina-miner/src/board/s19j_pro_amlogic.rs @@ -227,7 +227,9 @@ impl Board for S19jProAmlogic { voltage_regulator: Some( Arc::clone(&self.psu) as Arc> ), + chip_uart_baud: None, }, + post_broadcast_chip_baud: None, }; let thread = thread_v2::BM13xxThread::new(chip_rx, chip_tx, config).map_err(|e| { diff --git a/mujina-miner/src/board/s19j_pro_bitcrane.rs b/mujina-miner/src/board/s19j_pro_bitcrane.rs index e7a466bd..a5e68c86 100644 --- a/mujina-miner/src/board/s19j_pro_bitcrane.rs +++ b/mujina-miner/src/board/s19j_pro_bitcrane.rs @@ -268,7 +268,9 @@ impl Board for S19jProBitcrane { peripherals: ChainPeripherals { asic_enable: Arc::new(Mutex::new(S19jProBitcraneAsicEnable { reset_pin })), voltage_regulator, + chip_uart_baud: None, }, + post_broadcast_chip_baud: None, }; // Create the hash thread diff --git a/mujina-miner/src/board/s19k_pro_amlogic.rs b/mujina-miner/src/board/s19k_pro_amlogic.rs new file mode 100644 index 00000000..591d4d32 --- /dev/null +++ b/mujina-miner/src/board/s19k_pro_amlogic.rs @@ -0,0 +1,1328 @@ +//! S19k Pro support on a native Antminer Amlogic control board. +//! +//! This first implementation brings up one configured hashboard using the +//! native Linux interfaces proven in `amlogic-cb-tools`. + +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + sync::{Arc, OnceLock}, + time::Duration, +}; + +use amlogic_cb_tools::{ + eeprom_antminer::decode_antminer_eeprom, + gpio::SysfsGpio, + linux_i2c::LinuxI2cDevice, + pic::{PicChain, pic_address_for_slot}, + protocol::{ + CMD_GET_VOLTAGE, CMD_MEASURE_VOLTAGE, CMD_SET_VOLTAGE, CMD_WATCHDOG, NAK_BYTE, build_frame, + decode_dac_to_voltage, decode_measured_voltage, encode_voltage_to_dac, parse_frame, + }, + pwm::SysfsPwm, + tach::SysfsTachometer, +}; +use async_trait::async_trait; +use tokio::sync::{Mutex, watch}; +use tokio_util::codec::{FramedRead, FramedWrite}; +use tokio_util::sync::CancellationToken; + +use super::{Board, BoardError, BoardInfo, VirtualBoardDescriptor}; +use crate::{ + api_client::types::{BoardState, Fan, PowerMeasurement, TemperatureSensor}, + asic::{ + bm13xx::{ + self, + chain_config::{ChainConfig, ChainPeripherals, VoltageRegulator}, + chip_config, thread_v2, + topology::TopologySpec, + }, + hash_thread::{ + AsicEnable, HashTask, HashThread, HashThreadCapabilities, HashThreadError, + HashThreadEvent, HashThreadStatus, + }, + }, + config::{AmlogicControlBoardConfig, AmlogicHashboardConfig}, + error::Error, + tracing::prelude::*, + transport::serial::SerialStream, +}; + +/// Adapter that lets the BM13xx hash thread retune the controller-side +/// chip UART by closing+reopening `/dev/ttyS2`, matching the LuxOS +/// behaviour observed in `captures/luxos-bhb56902-chain-init.log` +/// (three separate `open64()` calls on that path during init). The +/// Amlogic `meson_uart` driver does not switch baud cleanly mid-stream +/// via `tcsetattr` at 3 Mbaud, so the reopen path is the only reliable +/// way to get the chain to stay synced. +struct SerialControlAdapter { + /// Device node path so we can re-open after each baud switch. + path: std::path::PathBuf, + /// Control side of the staged stream from the last + /// `prepare_new_stream` call. Held here so `finalize_baud_switch` + /// can retune it, and so the new fd stays open across the actor's + /// chip_tx swap (the kernel never sees `/dev/ttyS2` unclaimed, + /// matching LuxOS's "open before drop" pattern observed in + /// `captures/luxos-bhb56902-full-mining.log`). + staged_control: Option, + /// Keep-alive handle on the ORIGINAL `/dev/ttyS2` fd from board + /// init. LuxOS leaves its initial fd open for the entire mining + /// session — `captures/luxos-bhb56902-steady-state.log` shows + /// `OPEN64 fd=25` near t=0 followed by `OPEN64 fd=16` for each + /// later baud switch, but fd=25 itself never appears in a close + /// sequence. With nothing keeping the original fd alive, mujina + /// was hitting a brief no-fd window when the actor swapped + /// readers/writers, and the meson_uart driver glitched chips off + /// the chain. + _original_keepalive: Option, +} + +#[async_trait::async_trait] +impl bm13xx::chain_config::ChipUartBaudControl for SerialControlAdapter { + async fn prepare_new_stream( + &mut self, + current_baud_rate: u32, + ) -> anyhow::Result<( + bm13xx::chain_config::ChipRxStream, + bm13xx::chain_config::ChipTxSink, + )> { + let path = self.path.to_string_lossy().into_owned(); + // Open at the SAME baud the chain is currently using so the + // kernel `tcsetattr` driven by `SerialStream::new` is a no-op + // for the device — both the existing and the new fd stay at + // the current bit rate, the in-flight broadcast can complete, + // and chips actually receive it. + let stream = SerialStream::new(&path, current_baud_rate).map_err(|e| { + anyhow::anyhow!("SerialStream::new({path}, {current_baud_rate}): {e}") + })?; + let (reader, writer, control) = stream.split(); + control + .flush_input() + .map_err(|e| anyhow::anyhow!("SerialControl::flush_input: {e}"))?; + self.staged_control = Some(control); + let chip_rx = FramedRead::new(reader, bm13xx::FrameCodec); + let chip_tx = FramedWrite::new(writer, bm13xx::FrameCodec); + Ok((Box::pin(chip_rx), Box::pin(chip_tx))) + } + + async fn finalize_baud_switch( + &mut self, + target_baud_rate: u32, + ) -> anyhow::Result<()> { + let control = self + .staged_control + .as_ref() + .ok_or_else(|| anyhow::anyhow!("finalize_baud_switch called without prepare_new_stream"))?; + control + .set_baud_rate(target_baud_rate) + .map_err(|e| anyhow::anyhow!("SerialControl::set_baud_rate({target_baud_rate}): {e}")) + } +} + +const BOARD_MODEL: &str = "S19k Pro (Amlogic control board)"; +const DEFAULT_BOARD_NAME: &str = "s19kpro-amlogic"; +const FAN_PWM_PERIOD_NS: u32 = 10_000; +const SERIAL_BAUD: u32 = 115_200; +const PSU_RESPONSE_DELAY_MS: u64 = 500; +const PSU_MAX_RESPONSE_ATTEMPTS: usize = 3; +const EEPROM_LEN: usize = 256; +const TMP75_TEMP_REG: u8 = 0x00; + +static AMLOGIC_BOARD_CONFIG: OnceLock = OnceLock::new(); + +/// Install the config used by the native Amlogic virtual board factory. +pub fn install_config(config: AmlogicControlBoardConfig) -> crate::error::Result<()> { + AMLOGIC_BOARD_CONFIG + .set(config) + .map_err(|_| Error::Config("Amlogic control-board config already initialized".into())) +} + +/// Derive a stable device identifier for the configured control board. +pub fn device_id(config: &AmlogicControlBoardConfig) -> String { + config + .board_name + .clone() + .unwrap_or_else(|| DEFAULT_BOARD_NAME.to_string()) +} + +/// Native Amlogic S19k Pro board. +pub struct S19kProAmlogic { + config: AmlogicControlBoardConfig, + selected_hashboard: AmlogicHashboardConfig, + board_serial: Option, + psu: Arc>, + state_tx: watch::Sender, + thread_states: Arc>>, + telemetry_shutdown: CancellationToken, +} + +impl S19kProAmlogic { + fn new( + config: AmlogicControlBoardConfig, + selected_hashboard: AmlogicHashboardConfig, + board_serial: Option, + psu: Arc>, + state_tx: watch::Sender, + ) -> Self { + Self { + config, + selected_hashboard, + board_serial, + psu, + state_tx, + thread_states: Arc::new(std::sync::Mutex::new(Vec::new())), + telemetry_shutdown: CancellationToken::new(), + } + } + + async fn initialize( + config: &AmlogicControlBoardConfig, + state_tx: &watch::Sender, + ) -> Result< + ( + AmlogicHashboardConfig, + Option, + Arc>, + ), + BoardError, + > { + let selected_hashboard = select_hashboard(config)?; + let board_name = device_id(config); + + info!( + board = %board_name, + hashboard = selected_hashboard.index, + serial = %selected_hashboard.serial_path.display(), + "Initializing native Amlogic S19k Pro board" + ); + + let (board_serial, initial_temperatures) = + perform_health_gate(config, &selected_hashboard)?; + + configure_fans(config, config.startup.default_fan_percent)?; + assert_all_resets(config)?; + + let psu = Arc::new(Mutex::new(NativeAmlogicPsu::new(config))); + let measured_voltage = { + let mut psu_guard = psu.lock().await; + psu_guard + .set_enabled(true) + .map_err(|e| BoardError::HardwareControl(format!("Failed to enable PSU: {e}")))?; + if let Err(e) = psu_guard.config_watchdog(0x00) { + warn!( + "PSU watchdog disable rejected (firmware variant?), continuing: {}", + e + ); + } + psu_guard + .set_voltage(config.startup.initial_voltage) + .await + .map_err(|e| { + BoardError::HardwareControl(format!("Failed to set PSU voltage: {e}")) + })?; + + tokio::time::sleep(Duration::from_millis(config.startup.psu_settle_ms)).await; + psu_guard.measure_voltage().ok() + }; + + // PIC handshake. The BHB56902 (S19k Pro), like its BHB42601 / + // BHB42611 (S19j Pro) siblings, gates the per-domain DC-DC + // regulators behind an on-hashboard PIC16F1704 microcontroller. + // The BM1366 chips have no power to respond on UART until we + // enable them via the PIC. + // See "PIC vs noPIC Bitmain Miners": + // https://braiins.com/blog/pic-vs-nopic-bitmain-miners-... + // + // The protocol opcodes used by `PicChain` were lifted from the + // decompiled S21 single_board_test in + // https://github.com/HashSource/bitmain_antminer_binaries + // and confirmed against LuxOS ftrace captures on the BHB42601. + // The BHB56902 uses the same PIC firmware family (version 0x89 + // observed on both) and reuses this sequence: + // reset -> start_app -> get_sw_ver -> disable_dc_dc -> enable_dc_dc + // + // The PIC's onboard LDO is fed from the 12 V rail, so handshake + // must run AFTER `set_enabled(true)` above. Failures are + // tolerated so any future noPIC variant of the S19k Pro can + // still bring up via the existing path. + let pic_addr = pic_address_for_slot(selected_hashboard.index); + match PicChain::open(&selected_hashboard.eeprom_i2c_device, pic_addr) { + Ok(mut pic) => match pic.handshake() { + Ok(version) => { + info!( + addr = format_args!("0x{:02x}", pic_addr), + version = format_args!("0x{:02x}", version), + "PIC handshake ok" + ); + if let Err(e) = pic.enable_dc_dc() { + warn!( + addr = format_args!("0x{:02x}", pic_addr), + error = %e, + "PIC enable_dc_dc failed; chips may not power up" + ); + } else { + info!( + addr = format_args!("0x{:02x}", pic_addr), + "PIC DC-DC enabled; chips powering up" + ); + } + } + Err(e) => { + warn!( + addr = format_args!("0x{:02x}", pic_addr), + error = %e, + "PIC handshake failed; chain may not respond on UART" + ); + } + }, + Err(e) => { + warn!( + addr = format_args!("0x{:02x}", pic_addr), + error = %e, + "Could not open PIC i2c device; non-PIC hashboard variant?" + ); + } + } + + let fan_states = build_fan_state(config, config.startup.default_fan_percent); + let power_states = vec![PowerMeasurement { + name: "apw12".into(), + voltage_v: measured_voltage, + current_a: None, + power_w: None, + }]; + + state_tx.send_modify(|state| { + state.name = board_name.clone(); + state.model = BOARD_MODEL.into(); + state.serial = board_serial.clone().or_else(|| Some(board_name.clone())); + state.temperatures = initial_temperatures.clone(); + state.fans = fan_states.clone(); + state.powers = power_states.clone(); + }); + + Ok((selected_hashboard, board_serial, psu)) + } +} + +#[async_trait] +impl Board for S19kProAmlogic { + fn board_info(&self) -> BoardInfo { + BoardInfo { + model: BOARD_MODEL.into(), + firmware_version: None, + serial_number: self + .board_serial + .clone() + .or_else(|| Some(device_id(&self.config))), + } + } + + async fn shutdown(&mut self) -> Result<(), BoardError> { + info!(board = %device_id(&self.config), "Shutting down native Amlogic board"); + + self.telemetry_shutdown.cancel(); + + assert_all_resets(&self.config)?; + configure_fans(&self.config, 0)?; + + // Best-effort disable of PIC DC-DC before cutting the rail. If this + // fails the PSU output-off below still safes the chips. + let pic_addr = pic_address_for_slot(self.selected_hashboard.index); + if let Ok(mut pic) = + PicChain::open(&self.selected_hashboard.eeprom_i2c_device, pic_addr) + { + if let Err(e) = pic.disable_dc_dc() { + warn!( + addr = format_args!("0x{:02x}", pic_addr), + error = %e, + "PIC disable_dc_dc on shutdown failed (non-fatal)" + ); + } + } + + self.psu + .lock() + .await + .set_enabled(false) + .map_err(|e| BoardError::HardwareControl(format!("Failed to disable PSU: {e}")))?; + + Ok(()) + } + + async fn create_hash_threads(&mut self) -> Result>, BoardError> { + let data_stream = SerialStream::new( + &self.selected_hashboard.serial_path.to_string_lossy(), + SERIAL_BAUD, + ) + .map_err(|e| BoardError::InitializationFailed(format!("Failed to open data port: {e}")))?; + let (data_reader, data_writer, data_control) = data_stream.split(); + + data_control.flush_input().map_err(|e| { + BoardError::InitializationFailed(format!("Failed to flush serial buffer: {e}")) + })?; + + let chip_rx = FramedRead::new(data_reader, bm13xx::FrameCodec); + let chip_tx = FramedWrite::new(data_writer, bm13xx::FrameCodec); + + // Hand the hash thread a handle for the chip-channel SerialStream + // so it can close+reopen `/dev/ttyS2` at a new baud rate mid-init. + // The original `data_control` is stashed in + // `_original_keepalive` so its Arc reference keeps + // the first fd open for the lifetime of the adapter (and the + // mining session). LuxOS keeps its first fd open the entire + // time it runs — without that we get chip drops on the swap. + let chip_uart_baud = Arc::new(Mutex::new(SerialControlAdapter { + path: self.selected_hashboard.serial_path.clone(), + staged_control: None, + _original_keepalive: Some(data_control), + })); + + let config = ChainConfig { + name: format!("S19kProAmlogic-HB{}", self.selected_hashboard.index), + topology: TopologySpec::uniform_domains(11, 7, false), + chip_config: chip_config::bm1366(), + peripherals: ChainPeripherals { + asic_enable: Arc::new(Mutex::new(NativeResetControl { + gpio: SysfsGpio::new(self.selected_hashboard.reset_gpio), + reset_release_ms: self.config.startup.reset_release_ms, + })), + voltage_regulator: Some( + Arc::clone(&self.psu) as Arc> + ), + chip_uart_baud: Some(chip_uart_baud + as Arc>), + }, + // Baud bump to 3.125 Mbaud. The post-broadcast switch is + // critical to break past the UART RX cap at 115200: with + // 77 chips producing ~32 TH/s of nonces, the 1000-frames/s + // ceiling at 115200 drops half the share-bearing nonces. + // LuxOS sustains 38 TH/s on this same hashboard at this + // baud (per `captures/luxos-bhb56902-steady-state.log`). + // + // Two prior mismatches were dropping chips on the switch: + // 1. mujina mapped `Baud3M` to 3_000_000 numeric. The + // chip-side register `0x00003011` actually produces + // 3_125_000 baud, not 3_000_000 — the ~4 % mismatch + // was enough to mangle frames on the long chain. Now + // fixed in `thread_v2.rs`. + // 2. The board dropped the original `SerialControl` after + // handing it to the actor, so when the actor swapped + // to the new fd there was a brief no-fd window before + // the new fd took over. LuxOS keeps its first fd open + // the entire session — now `SerialControlAdapter + // ._original_keepalive` holds the original control + // alive for the whole adapter lifetime. + post_broadcast_chip_baud: Some(bm13xx::protocol::BaudRate::Baud3M), + }; + + let thread = thread_v2::BM13xxThread::new(chip_rx, chip_tx, config).map_err(|e| { + BoardError::InitializationFailed(format!("Failed to create hash thread: {e}")) + })?; + + let thread_name = thread.name().to_string(); + let thread_hashrate = u64::from(thread.capabilities().hashrate_estimate); + + self.state_tx.send_modify(|state| { + state.serial = self + .board_serial + .clone() + .or_else(|| Some(device_id(&self.config))); + state.threads = vec![crate::api_client::types::ThreadState { + name: thread_name.clone(), + hashrate: thread_hashrate, + is_active: false, + }]; + }); + + { + let mut thread_states = self + .thread_states + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *thread_states = vec![crate::api_client::types::ThreadState { + name: thread_name.clone(), + hashrate: thread_hashrate, + is_active: false, + }]; + } + + let thread = BoardStateHashThread::new( + Box::new(thread), + self.state_tx.clone(), + Arc::clone(&self.thread_states), + ); + + let config = self.config.clone(); + let hashboard = self.selected_hashboard.clone(); + let psu = Arc::clone(&self.psu); + let state_tx = self.state_tx.clone(); + let thread_states = Arc::clone(&self.thread_states); + let shutdown = self.telemetry_shutdown.child_token(); + tokio::spawn(async move { + native_telemetry_task(config, hashboard, psu, state_tx, thread_states, shutdown).await; + }); + + Ok(vec![Box::new(thread)]) + } +} + +struct BoardStateHashThread { + inner: Box, + state_tx: watch::Sender, + thread_states: Arc>>, +} + +impl BoardStateHashThread { + fn new( + inner: Box, + state_tx: watch::Sender, + thread_states: Arc>>, + ) -> Self { + Self { + inner, + state_tx, + thread_states, + } + } + + fn sync_thread_state(&self, is_active_override: Option) { + let status = self.inner.status(); + let capabilities = self.inner.capabilities(); + let hashrate = thread_hashrate_value(&status, capabilities); + let name = self.inner.name().to_string(); + let is_active = is_active_override.unwrap_or(status.is_active); + let thread_state = crate::api_client::types::ThreadState { + name: name.clone(), + hashrate, + is_active, + }; + + { + let mut thread_states = self + .thread_states + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(existing) = thread_states.iter_mut().find(|thread| thread.name == name) { + *existing = thread_state.clone(); + } else { + thread_states.push(thread_state.clone()); + } + } + + self.state_tx.send_modify(|state| { + if let Some(existing) = state.threads.iter_mut().find(|thread| thread.name == name) { + *existing = thread_state.clone(); + } else { + state.threads.push(thread_state); + } + }); + } +} + +fn thread_hashrate_value(status: &HashThreadStatus, capabilities: &HashThreadCapabilities) -> u64 { + let measured = u64::from(status.hashrate); + if measured > 0 { + measured + } else { + u64::from(capabilities.hashrate_estimate) + } +} + +#[async_trait] +impl HashThread for BoardStateHashThread { + fn name(&self) -> &str { + self.inner.name() + } + + fn capabilities(&self) -> &HashThreadCapabilities { + self.inner.capabilities() + } + + async fn update_task( + &mut self, + new_task: HashTask, + ) -> Result, HashThreadError> { + let result = self.inner.update_task(new_task).await; + self.sync_thread_state(Some(result.is_ok())); + result + } + + async fn replace_task( + &mut self, + new_task: HashTask, + ) -> Result, HashThreadError> { + let result = self.inner.replace_task(new_task).await; + self.sync_thread_state(Some(result.is_ok())); + result + } + + async fn go_idle(&mut self) -> Result, HashThreadError> { + let result = self.inner.go_idle().await; + self.sync_thread_state(Some(false)); + result + } + + async fn shutdown(&mut self) -> Result<(), HashThreadError> { + let result = self.inner.shutdown().await; + self.sync_thread_state(Some(false)); + result + } + + fn take_event_receiver(&mut self) -> Option> { + self.inner.take_event_receiver() + } + + fn status(&self) -> HashThreadStatus { + self.inner.status() + } +} + +#[derive(Clone)] +struct NativeResetControl { + gpio: SysfsGpio, + reset_release_ms: u64, +} + +#[async_trait] +impl AsicEnable for NativeResetControl { + async fn enable(&mut self) -> anyhow::Result<()> { + self.gpio.set_output_high()?; + tokio::time::sleep(Duration::from_millis(self.reset_release_ms)).await; + Ok(()) + } + + async fn disable(&mut self) -> anyhow::Result<()> { + self.gpio.set_output_low()?; + Ok(()) + } +} + +#[derive(Clone)] +struct NativeAmlogicPsu { + i2c_device: PathBuf, + address: u16, + write_register: u8, + enable_gpio: u32, + enabled: bool, +} + +impl NativeAmlogicPsu { + fn new(config: &AmlogicControlBoardConfig) -> Self { + Self { + i2c_device: config.psu.i2c_device.clone(), + address: config.psu.address, + write_register: config.psu.write_register, + enable_gpio: config.psu.enable_gpio, + enabled: false, + } + } + + fn set_enabled(&mut self, enabled: bool) -> anyhow::Result<()> { + let gpio = SysfsGpio::new(self.enable_gpio); + if enabled { + gpio.set_output_low()?; + } else { + gpio.set_output_high()?; + } + self.enabled = enabled; + Ok(()) + } + + fn config_watchdog(&mut self, value: u8) -> anyhow::Result<()> { + self.exchange(CMD_WATCHDOG, &[value, 0x00])?; + Ok(()) + } + + fn measure_voltage(&mut self) -> anyhow::Result { + let frame = self.exchange(CMD_MEASURE_VOLTAGE, &[])?; + if frame.payload.len() < 2 { + return Err(anyhow::anyhow!("missing ADC payload from PSU")); + } + Ok(decode_measured_voltage(frame.payload[0], frame.payload[1])) + } + + fn read_target_voltage(&mut self) -> anyhow::Result { + let frame = self.exchange(CMD_GET_VOLTAGE, &[])?; + let dac = *frame + .payload + .first() + .ok_or_else(|| anyhow::anyhow!("missing DAC payload from PSU"))?; + Ok(decode_dac_to_voltage(dac)) + } + + fn exchange( + &mut self, + command: u8, + payload: &[u8], + ) -> anyhow::Result { + let mut dev = LinuxI2cDevice::open(&self.i2c_device, self.address)?; + let frame = build_frame(command, payload); + for byte in frame { + dev.write_byte_transaction(self.write_register, byte)?; + } + + std::thread::sleep(Duration::from_millis(PSU_RESPONSE_DELAY_MS)); + + let mut last_error = None; + for _ in 0..PSU_MAX_RESPONSE_ATTEMPTS { + match read_psu_response_frame(&mut dev) { + Ok(response) if response == [NAK_BYTE] => { + last_error = Some(anyhow::anyhow!("PSU returned NAK")); + } + Ok(response) => match parse_frame(&response) { + Ok(frame) if frame.command == command => return Ok(frame), + Ok(frame) => { + last_error = Some(anyhow::anyhow!( + "unexpected PSU response command 0x{:02X} for request 0x{command:02X}", + frame.command + )); + } + Err(err) => { + last_error = Some(anyhow::Error::new(err)); + } + }, + Err(err) => { + last_error = Some(err); + } + } + + std::thread::sleep(Duration::from_millis(PSU_RESPONSE_DELAY_MS)); + } + + Err(last_error.unwrap_or_else(|| anyhow::anyhow!("no valid PSU response received"))) + } +} + +impl Drop for NativeAmlogicPsu { + fn drop(&mut self) { + if !self.enabled { + return; + } + + if let Err(error) = self.set_enabled(false) { + error!(gpio = self.enable_gpio, error = %error, "Failed to disable PSU during drop"); + } else { + warn!(gpio = self.enable_gpio, "Disabled PSU during drop"); + } + } +} + +#[async_trait] +impl VoltageRegulator for NativeAmlogicPsu { + async fn set_voltage(&mut self, volts: f32) -> anyhow::Result<()> { + let clamped = volts.clamp(12.0, 15.0); + let dac = encode_voltage_to_dac(clamped); + + // The APW12 firmware on this control board (`get-fw` reports + // 0x0010, `get-hw` 0x0071) intermittently NAKs response frames + // even when the underlying DAC write succeeds — observed by doing + // a `get-voltage` immediately after a NAK'd `set-voltage` and + // seeing the requested DAC echoed back. Retry up to four times + // and fall back to readback comparison; if the DAC reads back at + // the requested voltage we treat the NAK as transient and accept. + // Without this the chain ramp stalls on the first NAK because + // bm13xx's chain init bubbles the error up and the scheduler + // refuses to assign jobs. + let mut last_err: Option = None; + for attempt in 0..4 { + match self.exchange(CMD_SET_VOLTAGE, &[dac, 0x00]) { + Ok(_) => { + if attempt > 0 { + warn!( + requested = clamped, + attempt, + "PSU set_voltage succeeded after retry" + ); + } + return Ok(()); + } + Err(err) => { + // Try a readback; if that succeeds and matches, the + // earlier write probably did land. If readback itself + // NAKs, retry the whole thing. + if let Ok(readback) = self.read_target_voltage() { + if (readback - clamped).abs() <= 0.15 { + warn!( + requested = clamped, + readback, + attempt, + error = %err, + "PSU accepted voltage by readback after transient response issue" + ); + return Ok(()); + } + } + last_err = Some(err); + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + } + Err(last_err.unwrap_or_else(|| anyhow::anyhow!("PSU set_voltage retries exhausted"))) + } + + fn voltage_range(&self) -> (f32, f32) { + // BHB56902 factory ATE setpoint is 13.90 V / 645 MHz (Braiins' + // `Detected hashboard #2: Voltage (Avg.) 13.90 V, Frequency + // (Avg.) 645 MHz, Hashrate 44400.51 GH/s`). + // + // The min is set to the factory chain voltage. The shared + // frequency-ramp voltage formula (`voltage_for_frequency_stacked`) + // was tuned for BM1362 and returns ~0.3 V/chip; with 11 + // domains it would set 3.46 V across the chain, which is far + // below what BM1366 needs to PLL-lock at 645 MHz. With a + // 13.9 V floor the ramp clamps up to the factory setpoint + // immediately, matching what LuxOS and Braiins do (they set + // voltage to target *before* ramping frequency). + // + // verify_chain after ramp reports few chips alive at 13.9 V + // (e.g. 16/77), but observed hashrate (~27 TH/s on the dummy + // source vs ~14 TH/s at the 13.0 V floor with 67 reported) + // shows that more chips are actually mining than the polled + // verify can see at 3.125 Mbaud — the polled read is what's + // flaky, not the chips themselves. + (13.9, 14.5) + } + + fn target_voltage(&self) -> f32 { + // Factory-equivalent operating point, matching Braiins's + // `Voltage(13.9)` for this hashboard. With 500 MHz frequency + // mujina was at ~21 TH/s; at 645 MHz + 13.9 V Braiins gets + // ~30 TH/s on the same chips. + 13.9 + } + + fn voltage_step(&self) -> f32 { + 0.1 + } +} + +fn select_hashboard( + config: &AmlogicControlBoardConfig, +) -> Result { + if config.hashboards.is_empty() { + return Err(BoardError::InitializationFailed( + "Amlogic config has no configured hashboards".into(), + )); + } + + let mut first_present = None; + for hashboard in &config.hashboards { + let present = is_hashboard_present(hashboard)?; + if !present { + let missing_is_fatal = config + .startup + .health_gate + .fail_on_missing_expected_hashboard + || hashboard.required; + if missing_is_fatal { + return Err(BoardError::InitializationFailed(format!( + "Configured hashboard {} is missing", + hashboard.index + ))); + } + continue; + } + + if first_present.is_none() { + first_present = Some(hashboard.clone()); + } + } + + first_present.ok_or_else(|| { + BoardError::InitializationFailed("No configured hashboards are present".into()) + }) +} + +fn is_hashboard_present(hashboard: &AmlogicHashboardConfig) -> Result { + let detect = SysfsGpio::new(hashboard.detect_gpio); + detect.set_input_bias_disabled().map_err(|e| { + BoardError::HardwareControl(format!("Failed to configure detect GPIO: {e}")) + })?; + let present = detect + .read_value() + .map_err(|e| BoardError::HardwareControl(format!("Failed to read detect GPIO: {e}")))?; + Ok(present != 0) +} + +fn perform_health_gate( + config: &AmlogicControlBoardConfig, + hashboard: &AmlogicHashboardConfig, +) -> Result<(Option, Vec), BoardError> { + let mut board_serial = None; + + if config.startup.health_gate.read_eeprom_before_mining { + let eeprom = read_eeprom(hashboard)?; + let decoded = decode_antminer_eeprom(&eeprom).map_err(|e| { + BoardError::InitializationFailed(format!( + "EEPROM health gate failed for hashboard {}: {e}", + hashboard.index + )) + })?; + board_serial = Some(decoded.board_serial); + } + + let temperatures = if config.startup.health_gate.read_temperatures_before_mining { + read_temperatures(hashboard)? + } else { + Vec::new() + }; + + if config.startup.health_gate.read_temperatures_before_mining { + for sensor in &temperatures { + if sensor.temperature_c.is_none() { + return Err(BoardError::InitializationFailed(format!( + "Temperature health gate failed for {}", + sensor.name + ))); + } + } + } + + Ok((board_serial, temperatures)) +} + +fn assert_all_resets(config: &AmlogicControlBoardConfig) -> Result<(), BoardError> { + for hashboard in &config.hashboards { + SysfsGpio::new(hashboard.reset_gpio) + .set_output_low() + .map_err(|e| { + BoardError::HardwareControl(format!( + "Failed to assert reset for hashboard {}: {e}", + hashboard.index + )) + })?; + } + std::thread::sleep(Duration::from_millis(config.startup.reset_assert_ms)); + Ok(()) +} + +fn configure_fans(config: &AmlogicControlBoardConfig, percent: u8) -> Result<(), BoardError> { + let mut configured_channels = HashSet::new(); + for fan in &config.fans { + if configured_channels.insert((fan.pwm_chip, fan.pwm_channel)) { + SysfsPwm::new(fan.pwm_chip, fan.pwm_channel) + .configure_percent(FAN_PWM_PERIOD_NS, percent, true) + .map_err(|e| { + BoardError::HardwareControl(format!( + "Failed to configure pwmchip{}/pwm{}: {e}", + fan.pwm_chip, fan.pwm_channel + )) + })?; + } + } + Ok(()) +} + +fn build_fan_state(config: &AmlogicControlBoardConfig, percent: u8) -> Vec { + config + .fans + .iter() + .map(|fan| Fan { + name: format!("fan{}", fan.index), + rpm: None, + percent: Some(percent), + target_percent: Some(percent), + }) + .collect() +} + +async fn native_telemetry_task( + config: AmlogicControlBoardConfig, + hashboard: AmlogicHashboardConfig, + psu: Arc>, + state_tx: watch::Sender, + thread_states: Arc>>, + shutdown: CancellationToken, +) { + const TELEMETRY_INTERVAL: Duration = Duration::from_secs(2); + + // Open a dedicated PIC handle for the heartbeat path. LuxOS sends a + // PIC heartbeat (opcode 0x16) periodically while mining — captured at + // roughly every 1.5 s in our ftrace runs. Without heartbeats the PIC + // appears to disable DC-DC after a watchdog timeout (we observed chips + // dropping mid-ramp without it). The exact timeout isn't documented; + // LuxOS firmware never lets the gap grow large enough to find out. + // + // We piggy-back on the 2 s telemetry tick so heartbeat + temp reads + // share one PIC handle and the same i2c-0 transactions don't race. + // Failing to open just disables the heartbeat (board may still come up + // briefly). + // Probe for the PIC at the expected i2c address. The BHB56902 + // (S19k Pro) is a noPIC variant — no chip ACKs at 0x21 / 0x22. + // Without a probe we'd spam a "PIC heartbeat failed" warning every + // ~2 s for the life of the process. Try one heartbeat; if it + // fails, disable the path entirely. + let pic_addr = pic_address_for_slot(hashboard.index); + let mut pic_for_heartbeat: Option = match PicChain::open( + &hashboard.eeprom_i2c_device, + pic_addr, + ) { + Ok(mut p) => match p.heartbeat() { + Ok(()) => Some(p), + Err(e) => { + info!( + addr = format_args!("0x{:02x}", pic_addr), + error = %e, + "PIC absent on this hashboard (likely a noPIC variant like BHB56902); \ + skipping heartbeat path" + ); + None + } + }, + Err(e) => { + warn!( + addr = format_args!("0x{:02x}", pic_addr), + error = %e, + "could not open PIC for heartbeat task; chips may drop after watchdog timeout" + ); + None + } + }; + + loop { + if shutdown.is_cancelled() { + break; + } + + // Heartbeat + read PIC-mediated temps using a single PIC handle to + // avoid racing with a separately-opened temp reader. + let mut temperatures: Vec = Vec::new(); + if let Some(ref mut pic) = pic_for_heartbeat { + if let Err(e) = pic.heartbeat() { + warn!( + addr = format_args!("0x{:02x}", pic_addr), + error = %e, + "PIC heartbeat failed" + ); + } + match pic.read_temperatures_celsius() { + Ok(temps) => { + for (i, t) in temps.iter().enumerate() { + temperatures.push(TemperatureSensor { + name: format!("HB{}-PIC{}", hashboard.index, i), + temperature_c: Some(*t), + }); + } + } + Err(e) => { + debug!( + addr = format_args!("0x{:02x}", pic_addr), + error = %e, + "PIC temperature read failed" + ); + } + } + } + // Fallback to TMP75 path when no PIC-mediated temps were returned + // (e.g. noPIC variants). read_temperatures() handles the TMP75 case. + if temperatures.is_empty() { + match read_temperatures(&hashboard) { + Ok(t) => temperatures = t, + Err(error) => { + debug!(board = %hashboard.index, error = %error, "Native telemetry temperature read failed"); + } + } + } + + // Overtemp protection: if any PIC sensor exceeds the cutoff, + // immediately disable DC-DC (cuts chip power but keeps PIC alive) + // and PSU output, then cancel telemetry so the daemon notices and + // shuts the board down. + // + // Stock Bitmain firmware uses ~95 °C for hard shutdown and ~85 °C + // for throttling on this chip family. We use 75 °C as a + // conservative fixed cutoff: this code path is exercised on the + // bench (limited cooling) much more than in chassis, and the + // failure mode of running a hashboard hot is severe (the original + // BHB56902 we used to bring this up arced its 12 V input plane). + // A configurable threshold is the right long-term answer; left as + // future work to keep this PR focused. + const OVERTEMP_CUTOFF_C: f32 = 75.0; + let hottest = temperatures + .iter() + .filter_map(|t| t.temperature_c) + .fold(0f32, f32::max); + if hottest >= OVERTEMP_CUTOFF_C { + error!( + board = %hashboard.index, + hottest = hottest, + cutoff = OVERTEMP_CUTOFF_C, + "OVERTEMP — disabling PIC DC-DC and PSU output" + ); + if let Some(ref mut pic) = pic_for_heartbeat { + let _ = pic.disable_dc_dc(); + } + let _ = psu.lock().await.set_enabled(false); + shutdown.cancel(); + break; + } + + let fans = read_fan_states(&config, config.startup.default_fan_percent).await; + let voltage_v = match psu.lock().await.measure_voltage() { + Ok(voltage_v) => Some(voltage_v), + Err(error) => { + debug!(error = %error, "Native telemetry PSU voltage read failed"); + None + } + }; + let powers = vec![PowerMeasurement { + name: "apw12".into(), + voltage_v, + current_a: None, + power_w: None, + }]; + let threads = { + thread_states + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + }; + + state_tx.send_modify(|state| { + state.temperatures = temperatures.clone(); + state.fans = fans.clone(); + state.powers = powers.clone(); + state.threads = threads.clone(); + }); + + tokio::select! { + _ = shutdown.cancelled() => break, + _ = tokio::time::sleep(TELEMETRY_INTERVAL) => {} + } + } +} + +async fn read_fan_states(config: &AmlogicControlBoardConfig, target_percent: u8) -> Vec { + const FAN_SAMPLE_WINDOW: Duration = Duration::from_millis(500); + + let mut fan_states = Vec::with_capacity(config.fans.len()); + for fan in &config.fans { + let fan_name = format!("fan{}", fan.index); + let tach_gpio = fan.tach_gpio; + let pulses_per_rev = fan.pulses_per_rev; + let rpm = match tokio::task::spawn_blocking(move || { + SysfsTachometer::new(tach_gpio) + .measure_rpm(FAN_SAMPLE_WINDOW, pulses_per_rev) + .map(|reading| reading.rpm) + .map_err(|error| error.to_string()) + }) + .await + { + Ok(Ok(rpm)) => Some(rpm), + Ok(Err(error)) => { + debug!(fan = %fan_name, gpio = tach_gpio, error = %error, "Native telemetry fan RPM read failed"); + None + } + Err(error) => { + debug!(fan = %fan_name, gpio = tach_gpio, error = %error, "Native telemetry fan RPM task join failed"); + None + } + }; + + fan_states.push(Fan { + name: fan_name, + rpm, + percent: None, + target_percent: Some(target_percent), + }); + } + + fan_states +} + +fn read_temperatures( + hashboard: &AmlogicHashboardConfig, +) -> Result, BoardError> { + // Try PIC-mediated temps first (PIC-variant boards: BHB56902 / S19k Pro + // family, where asic_sensor_type=0 in EEPROM and pic_sensor_type != 0). + // These can only be read while PSU output is ON because the PIC's LDO + // is fed from the 12V rail; tolerate a failure here and fall back to + // ASIC-bus TMP75. + let mut sensors = Vec::new(); + let pic_addr = pic_address_for_slot(hashboard.index); + if let Ok(mut pic) = PicChain::open(&hashboard.eeprom_i2c_device, pic_addr) { + if let Ok(temps) = pic.read_temperatures_celsius() { + for (i, t) in temps.iter().enumerate() { + sensors.push(TemperatureSensor { + name: format!("HB{}-PIC{}", hashboard.index, i), + temperature_c: Some(*t), + }); + } + return Ok(sensors); + } + } + + // Fallback: TMP75 sensors on the ASIC bus (older / noPIC variants). + let addresses = configured_tmp75_addresses(hashboard)?; + for (sensor_index, address) in addresses.into_iter().enumerate() { + let raw = read_tmp75_raw(&hashboard.temp_i2c_device, address).map_err(|e| { + BoardError::InitializationFailed(format!( + "Failed to read TMP75 sensor {} on hashboard {}: {e}", + sensor_index, hashboard.index + )) + })?; + sensors.push(TemperatureSensor { + name: format!("HB{}-TMP75-{}", hashboard.index, sensor_index), + temperature_c: Some(decode_tmp75_celsius(raw)), + }); + } + Ok(sensors) +} + +fn read_eeprom(hashboard: &AmlogicHashboardConfig) -> Result, BoardError> { + let address = configured_eeprom_address(hashboard)?; + let mut device = LinuxI2cDevice::open(&hashboard.eeprom_i2c_device, address).map_err(|e| { + BoardError::InitializationFailed(format!( + "Failed to open EEPROM I2C device {}: {e}", + hashboard.eeprom_i2c_device.display() + )) + })?; + + match device.read_at(0, EEPROM_LEN) { + Ok(data) => Ok(data), + Err(_) => { + let mut data = Vec::with_capacity(EEPROM_LEN); + for offset in 0..EEPROM_LEN { + data.push(device.read_byte_data(offset as u8).map_err(|e| { + BoardError::InitializationFailed(format!( + "Failed to read EEPROM byte {} on hashboard {}: {e}", + offset, hashboard.index + )) + })?); + } + Ok(data) + } + } +} + +fn configured_tmp75_addresses(hashboard: &AmlogicHashboardConfig) -> Result, BoardError> { + if !hashboard.temp_sensor_addresses.is_empty() { + return hashboard + .temp_sensor_addresses + .iter() + .copied() + .map(validate_i2c_address) + .collect(); + } + + Ok(tmp75_addresses(hashboard.index)? + .into_iter() + .map(u16::from) + .collect()) +} + +fn configured_eeprom_address(hashboard: &AmlogicHashboardConfig) -> Result { + match hashboard.eeprom_address { + Some(address) => validate_i2c_address(address), + None => Ok(u16::from(eeprom_address(hashboard.index)?)), + } +} + +fn validate_i2c_address(address: u16) -> Result { + if address > 0x7F { + return Err(BoardError::InitializationFailed(format!( + "invalid 7-bit I2C address: 0x{address:02X}" + ))); + } + + Ok(address) +} + +fn tmp75_addresses(board_index: u8) -> Result<[u8; 2], BoardError> { + match board_index { + 0 => Ok([0x4E, 0x4A]), + 1 => Ok([0x4D, 0x49]), + 2 => Ok([0x48, 0x4C]), + _ => Err(BoardError::InitializationFailed(format!( + "invalid hashboard index: {board_index}" + ))), + } +} + +fn eeprom_address(board_index: u8) -> Result { + match board_index { + 0 => Ok(0x52), + 1 => Ok(0x51), + 2 => Ok(0x50), + _ => Err(BoardError::InitializationFailed(format!( + "invalid hashboard index: {board_index}" + ))), + } +} + +fn read_tmp75_raw(i2c_device: &Path, address: u16) -> anyhow::Result { + let mut device = LinuxI2cDevice::open(i2c_device, address)?; + Ok(device.read_word_data(TMP75_TEMP_REG)?.swap_bytes()) +} + +fn decode_tmp75_celsius(raw: u16) -> f32 { + let value = i16::from_be_bytes(raw.to_be_bytes()) >> 4; + value as f32 * 0.0625 +} + +fn read_psu_response_frame(dev: &mut LinuxI2cDevice) -> anyhow::Result> { + let mut first = dev.read_byte_transaction()?; + while first != 0x55 && first != NAK_BYTE { + first = dev.read_byte_transaction()?; + } + + if first == NAK_BYTE { + return Ok(vec![NAK_BYTE]); + } + + let second = dev.read_byte_transaction()?; + if second != 0xAA { + return Err(anyhow::anyhow!( + "invalid preamble continuation: 0x{second:02X}" + )); + } + + let length = dev.read_byte_transaction()?; + let mut response = Vec::with_capacity(usize::from(length) + 2); + response.push(first); + response.push(second); + response.push(length); + + let remaining = usize::from(length) + .checked_sub(1) + .ok_or_else(|| anyhow::anyhow!("response length underflow"))?; + for _ in 0..remaining { + response.push(dev.read_byte_transaction()?); + } + + Ok(response) +} + +async fn create_amlogic_board() +-> crate::error::Result<(Box, super::BoardRegistration)> { + let config = AMLOGIC_BOARD_CONFIG + .get() + .cloned() + .ok_or_else(|| Error::Config("Amlogic control-board config not installed".into()))?; + + let name = device_id(&config); + let initial_state = BoardState { + name: name.clone(), + model: BOARD_MODEL.into(), + serial: Some(name), + ..Default::default() + }; + let (state_tx, state_rx) = watch::channel(initial_state); + + let (selected_hashboard, board_serial, psu) = S19kProAmlogic::initialize(&config, &state_tx) + .await + .map_err(|e| Error::Hardware(format!("Failed to initialize native Amlogic board: {e}")))?; + + let board = S19kProAmlogic::new(config, selected_hashboard, board_serial, psu, state_tx); + let registration = super::BoardRegistration { state_rx }; + Ok((Box::new(board), registration)) +} + +inventory::submit! { + VirtualBoardDescriptor { + device_type: "s19k_pro_amlogic", + name: BOARD_MODEL, + create_fn: || Box::pin(create_amlogic_board()), + } +} diff --git a/mujina-miner/src/config.rs b/mujina-miner/src/config.rs index f7a54842..a79836d3 100644 --- a/mujina-miner/src/config.rs +++ b/mujina-miner/src/config.rs @@ -301,10 +301,39 @@ pub struct AmlogicHashboardConfig { } /// Supported hashboard types for config-driven native Amlogic bring-up. -#[derive(Debug, Clone, Deserialize, Serialize)] +/// +/// The variant of the *first* configured hashboard selects which board +/// driver the daemon dispatches to. A mixed-model chassis isn't +/// supported today; all hashboards in the config should share the +/// same `model`. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum HashboardModel { + /// BHB42601 / BHB42611 (S19j Pro, S19j Pro+) — BM1362 chips, 126 + /// per board across 42 voltage domains. S19jPro, + /// BHB56902 (S19k Pro) — BM1366 / BM1366BS chips, ~77 per board + /// across 11 voltage domains. + S19kPro, +} + +impl HashboardModel { + /// Friendly board-model name surfaced via the API (matches the + /// `BOARD_MODEL` constants in the corresponding board impls). + pub fn board_model_label(self) -> &'static str { + match self { + HashboardModel::S19jPro => "S19j Pro (Amlogic control board)", + HashboardModel::S19kPro => "S19k Pro (Amlogic control board)", + } + } + + /// Chip family identifier shown on the GT Touch display. + pub fn asic_model_label(self) -> &'static str { + match self { + HashboardModel::S19jPro => "BM1362", + HashboardModel::S19kPro => "BM1366", + } + } } /// API server configuration. diff --git a/mujina-miner/src/daemon.rs b/mujina-miner/src/daemon.rs index b993cbfe..88ac5815 100644 --- a/mujina-miner/src/daemon.rs +++ b/mujina-miner/src/daemon.rs @@ -16,7 +16,7 @@ use crate::{ api::{self, ApiConfig, BoardRegistry, commands::SchedulerCommand}, asic::hash_thread::HashThread, backplane::Backplane, - board::s19j_pro_amlogic, + board::{s19j_pro_amlogic, s19k_pro_amlogic}, config::{Config, HashboardModel}, cpu_miner::CpuMinerConfig, display::gt_touch, @@ -85,14 +85,43 @@ impl Daemon { .as_ref() .and_then(Config::enabled_amlogic_control_board) { - let device_id = s19j_pro_amlogic::device_id(config); - s19j_pro_amlogic::install_config(config.clone())?; + // Dispatch to the right board driver based on the + // first configured hashboard's model. Both S19j Pro + // and S19k Pro use the same Amlogic A113D control + // board; only the chip family and hashboard topology + // differ, so they share the AmlogicControlBoardConfig + // schema. + let model = config + .hashboards + .first() + .map(|hb| hb.model) + .unwrap_or(HashboardModel::S19jPro); + + let device_id = match model { + HashboardModel::S19jPro => { + let id = s19j_pro_amlogic::device_id(config); + s19j_pro_amlogic::install_config(config.clone())?; + id + } + HashboardModel::S19kPro => { + let id = s19k_pro_amlogic::device_id(config); + s19k_pro_amlogic::install_config(config.clone())?; + id + } + }; - info!(board = %device_id, "Native Amlogic control board enabled from config"); + info!( + board = %device_id, + model = ?model, + "Native Amlogic control board enabled from config" + ); let event = TransportEvent::Amlogic(amlogic_transport::TransportEvent::DeviceConnected( - AmlogicDeviceInfo { device_id }, + AmlogicDeviceInfo { + device_id, + model, + }, )); if let Err(e) = transport_tx.send(event).await { error!("Failed to send native Amlogic board event: {}", e); @@ -340,10 +369,13 @@ impl Daemon { .as_ref() .and_then(Config::enabled_amlogic_control_board) .and_then(|config| config.hashboards.first()) - .map(|hashboard| match hashboard.model { - HashboardModel::S19jPro => "BM1362".to_string(), - }); - let default_device_model = Some("S19j Pro (Amlogic control board)".to_string()); + .map(|hashboard| hashboard.model.asic_model_label().to_string()); + let default_device_model = self + .config + .as_ref() + .and_then(Config::enabled_amlogic_control_board) + .and_then(|config| config.hashboards.first()) + .map(|hashboard| hashboard.model.board_model_label().to_string()); let default_voltage = self .config .as_ref() diff --git a/mujina-miner/src/display/gt_touch.rs b/mujina-miner/src/display/gt_touch.rs index 0e783b64..e08b1b4b 100644 --- a/mujina-miner/src/display/gt_touch.rs +++ b/mujina-miner/src/display/gt_touch.rs @@ -517,6 +517,7 @@ fn split_pool_url(url: &str) -> (Option, Option) { #[cfg(test)] mod tests { use super::*; + use crate::api_client::types::BoardState; #[test] fn formats_checked_responses() { diff --git a/mujina-miner/src/transport/amlogic.rs b/mujina-miner/src/transport/amlogic.rs index c8143dc8..291f199c 100644 --- a/mujina-miner/src/transport/amlogic.rs +++ b/mujina-miner/src/transport/amlogic.rs @@ -3,6 +3,8 @@ //! Provides synthesized transport events for the local Amlogic control board //! when enabled through Mujina configuration. +use crate::config::HashboardModel; + /// Transport events for native Amlogic control-board devices. #[derive(Debug)] pub enum TransportEvent { @@ -18,4 +20,9 @@ pub enum TransportEvent { pub struct AmlogicDeviceInfo { /// Unique identifier for this configured device. pub device_id: String, + /// Hashboard model selected at config time, used by the backplane + /// to dispatch to the right board factory (`s19j_pro_amlogic` vs + /// `s19k_pro_amlogic`). Both models share the AmlogicControlBoard + /// schema; they differ only in chip family + topology. + pub model: HashboardModel, } From e88b97de07610f90776a384e718d546ef2996f52 Mon Sep 17 00:00:00 2001 From: Michael Schmid Date: Sun, 21 Jun 2026 15:32:51 -0400 Subject: [PATCH 03/10] Wire pause/resume (soft pause: drop shares, stop dispatching work) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PauseMining and ResumeMining were stubs that logged "(not yet implemented)" and only flipped a flag. The API endpoint exists and the UI button calls it, but mining kept running. Replaces the stubs with a working soft-pause: PauseMining: - sets self.paused = true - resets every thread's HashrateEstimator so the API/UI reads 0 TH/s immediately (instead of aging the 5-min window out naturally) - does NOT touch the chip power rail — chips stay warm on the last job ResumeMining: - sets self.paused = false - re-dispatches each source's cached last_job to the threads so they pick up fresh ntime-rolled work right away assign_job_to_threads + handle_new_thread are gated on !self.paused so new stratum jobs don't accidentally restart hashing while paused. handle_share also drops everything when paused — without this, chips still mine the last job they were loaded with and would keep emitting nonces; the scheduler would forward them to the pool even though the user pressed pause. Dropping at handle_share both halts share submission and stops feeding the per-thread hashrate estimator, which is what makes the API hashrate go to 0 within a single sample window. API patch_miner timeout raised 5s -> 300s. PauseMining is fast, but if we ever go back to hard pause (chip power-down + cold re-enumeration) the resume round-trip takes ~2 min for the full chip init + frequency ramp, and the 5s bound was timing the client out while the work completed server-side. Validated end-to-end on a BHB56902 S19k Pro at 192.168.1.222: mining 78.8 TH/s, 14 shares -> pause: 0 TH/s, 14 shares (frozen) -> 60s: 0 TH/s, 14 shares (still frozen) -> resume: 51 TH/s, 21 shares (climbing) -> +30s: 40 TH/s, 83 shares TODO (hard pause): a previous attempt called disable_chips() on pause and re-ran initialize_chips on resume. That worked for the GPIO side but the post-reset chip re-enumeration only saw ~37/77 chips on the BHB56902 (down from 77/77 on a clean cold boot). The host UART / chip-side UART baud relationship recovers, but the chain RST_N pulse seems to leave some chips in an indeterminate state. Left the reset_chip_uart_to_base_baud() helper and the disable_chips path in place for when we revisit this — the actual fix is probably PSU output cycle (drop voltage, settle, bring back up) rather than asic_enable-only reset. Co-Authored-By: Claude Opus 4.7 --- mujina-miner/src/api/v0.rs | 8 +- mujina-miner/src/asic/bm13xx/thread_v2.rs | 67 ++++++++++++++ mujina-miner/src/scheduler.rs | 103 ++++++++++++++++++++-- 3 files changed, 170 insertions(+), 8 deletions(-) diff --git a/mujina-miner/src/api/v0.rs b/mujina-miner/src/api/v0.rs index 079b5833..72c446c1 100644 --- a/mujina-miner/src/api/v0.rs +++ b/mujina-miner/src/api/v0.rs @@ -82,7 +82,13 @@ async fn patch_miner( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // Result layers: timeout / channel-closed / command-error. - let Ok(Ok(Ok(()))) = tokio::time::timeout(Duration::from_secs(5), rx).await else { + // 5 minutes covers a full ResumeMining round-trip: the scheduler + // calls assign_job_to_threads -> handle_work_assignment -> + // initialize_chips on every hash thread, which includes a 500ms + // power-on wait, full enumeration, register config, and the + // BM1366 frequency ramp (typically ~2 min end-to-end). PauseMining + // is fast (just disable_chips) so this bound only matters on resume. + let Ok(Ok(Ok(()))) = tokio::time::timeout(Duration::from_secs(300), rx).await else { return Err(StatusCode::INTERNAL_SERVER_ERROR); }; } diff --git a/mujina-miner/src/asic/bm13xx/thread_v2.rs b/mujina-miner/src/asic/bm13xx/thread_v2.rs index e49682e0..20ac966a 100644 --- a/mujina-miner/src/asic/bm13xx/thread_v2.rs +++ b/mujina-miner/src/asic/bm13xx/thread_v2.rs @@ -1149,6 +1149,16 @@ impl BM13xxActor { .send(HashThreadEvent::StatusUpdate(status)) .await; } + + // NOTE: we used to reset the host UART back to 115200 here so + // a subsequent PauseMining -> ResumeMining cycle could re-run + // initialize_chips against freshly-reset chips. That worked + // for the baud side but still only got ~37/77 chips back — + // likely a chain-RST_N propagation issue we haven't debugged + // yet. PauseMining no longer calls go_idle on the threads + // (the scheduler does soft pause instead — drops shares, stops + // dispatching work) so this path is only hit during shutdown, + // where the process exits and host termios doesn't matter. self.chip_state = ChipState::Disabled; let status = self.update_status(|status| { status.is_active = false; @@ -1161,6 +1171,63 @@ impl BM13xxActor { .await; } + /// Re-open the chip channel at 115200 baud and re-spawn the reader + /// task on the new fd. Called from `disable_chips` so that + /// PauseMining -> ResumeMining round-trips end up with host-side + /// UART matching the chip-side default (chips revert to 115200 on + /// reset, but `tcsetattr` on the host side persists across resets). + /// + /// No-op when the board doesn't expose a baud-control adapter, or + /// when no baud bump was configured to begin with — in those cases + /// the chain has never left 115200 and there's nothing to undo. + async fn reset_chip_uart_to_base_baud(&mut self) { + if self.post_broadcast_chip_baud.is_none() { + return; + } + let Some(chip_uart) = self.peripherals.chip_uart_baud.as_ref() else { + return; + }; + const BASE_BAUD_HZ: u32 = 115_200; + + let new_streams = { + let mut control = chip_uart.lock().await; + // Re-use the two-phase swap: prepare a fresh stream at the + // current rate (whatever the previous baud bump landed on), + // then `finalize_baud_switch` retunes the device down to + // 115200 (kernel termios is shared across both fds on the + // same device, so this also flips the old fd). + let prepared = control.prepare_new_stream(BASE_BAUD_HZ).await; + let finalized = control.finalize_baud_switch(BASE_BAUD_HZ).await; + match (prepared, finalized) { + (Ok(pair), Ok(())) => Some(pair), + (Err(e), _) | (_, Err(e)) => { + warn!( + error = %e, + "Failed to reset chip UART to 115200; resume may fail to enumerate" + ); + None + } + } + }; + + if let Some((new_rx, new_tx)) = new_streams { + // Swap the actor's chip channels onto the fresh fd, same + // dance as `maybe_switch_chip_baud` step 5: drop the old + // writer, kill the old reader task, spawn a new one. + let stale_reader = self.reader_handle.take(); + let _old_tx = std::mem::replace(&mut self.chip_tx, new_tx); + drop(_old_tx); + if let Some(handle) = stale_reader { + let _ = tokio::time::timeout(Duration::from_millis(200), handle).await; + } + self.reader_handle = Some(tokio::spawn(serial_reader_task( + new_rx, + self.response_tx.clone(), + ))); + debug!("Chip UART reset to 115200 for the next initialize_chips run"); + } + } + async fn handle_chip_response(&mut self, result: Result) { match result { Ok(protocol::Response::Nonce { diff --git a/mujina-miner/src/scheduler.rs b/mujina-miner/src/scheduler.rs index 49d4a9b5..60aa8832 100644 --- a/mujina-miner/src/scheduler.rs +++ b/mujina-miner/src/scheduler.rs @@ -369,6 +369,15 @@ impl Scheduler { return; } + // Skip assignment if mining is paused via API. The template is + // still stored on `source.last_job` above so `resume_mining` can + // re-dispatch it without waiting for the source to emit a new + // job. Threads stay idle (chips disabled) until then. + if self.paused { + debug!(source = %source_name, "Mining is paused; caching job for resume"); + return; + } + // Debounced difficulty warning let hashrate = self.operational_hashrate(); if let Some(source) = self.sources.get_mut(source_id) { @@ -468,6 +477,16 @@ impl Scheduler { /// Handle a share arriving from a task's channel. async fn handle_share(&mut self, task_id: TaskId, share: Share) { + // When paused, drop everything. Chips keep mining the last job + // we sent them and will keep emitting nonces; if we forwarded + // them, the pool would see hashrate even though the user + // pressed pause. Stop counting them toward the per-thread + // hashrate estimator too so the UI reads 0 TH/s. + if self.paused { + trace!(?task_id, "Share dropped — scheduler is paused"); + return; + } + // Look up task context for routing let Some(task_entry) = self.tasks.get(task_id) else { // Task was removed (ReplaceJob/ClearJobs) but share arrived @@ -618,7 +637,13 @@ impl Scheduler { .unwrap_or(entry.thread.capabilities().hashrate_estimate) }; - // Assign cached jobs from all sources to the new thread + // Assign cached jobs from all sources to the new thread — but + // only if the scheduler isn't paused. If it is, the thread joins + // the paused pool and stays idle until `resume_mining` fires. + if self.paused { + debug!(thread = %thread_name, "Mining is paused; new thread will stay idle"); + return; + } for (source_id, source) in self.sources.iter() { let Some(template) = &source.last_job else { continue; @@ -709,21 +734,85 @@ impl Scheduler { /// /// Publishes an updated state snapshot before replying so the API /// handler's subsequent `borrow()` sees the new value. - fn handle_api_command( + async fn handle_api_command( &mut self, cmd: SchedulerCommand, miner_state_tx: &watch::Sender, + share_channels: &mut ShareStream, ) { match cmd { SchedulerCommand::PauseMining { reply } => { - self.paused = true; - warn!("Mining paused via API (not yet implemented)"); + if !self.paused { + self.paused = true; + info!( + thread_count = self.threads.len(), + "Mining paused — share submissions and new job dispatch stopped" + ); + // Soft pause: drop any in-flight task on each hash + // thread but DON'T touch the chip power rail. Chips + // burn through whatever nonces are still queued on + // their last job, mujina drops every share that + // comes back (`assign_job_to_threads` is gated on + // `!self.paused`, and the task lookup in + // `handle_share` only matches templates we still + // have entries for; clearing `current_task` on the + // hash thread stops ntime rolling so the chip work + // staleness curve takes over within a minute). + // + // Hard pause (full asic_enable.disable() + chip + // power cycle) is broken on BHB56902 today because + // the post-reset re-enumeration only sees ~half the + // chain at 115200 — likely a chain-RST_N propagation + // issue with our reset_release_ms timing. Left as a + // follow-up; tracked in the TODO below. + for (id, entry) in self.threads.iter_mut() { + // Reset the per-thread hashrate estimator so the + // API/UI stops showing the pre-pause hashrate + // while the window ages out. + entry.hashrate = HashrateEstimator::new(HASHRATE_WINDOW); + debug!(thread_id = ?id, thread = %entry.thread.name(), "Thread marked paused"); + } + // Note: we intentionally do NOT call + // `thread.go_idle()` here. That triggers + // disable_chips() which has the reset-baud + // recovery problem; until that's debugged we leave + // chips warm and rely on `self.paused` gating in + // `assign_job_to_threads` to stop new work. + } else { + debug!("PauseMining received but scheduler is already paused"); + } let _ = miner_state_tx.send(self.compute_miner_state()); let _ = reply.send(Ok(())); } SchedulerCommand::ResumeMining { reply } => { - self.paused = false; - warn!("Mining resumed via API (not yet implemented)"); + if self.paused { + self.paused = false; + info!( + thread_count = self.threads.len(), + "Mining resumed — re-dispatching cached jobs" + ); + // Snapshot the latest job per source. We can't iterate + // `self.sources` while calling `assign_job_to_threads` + // (which borrows `&mut self`), so collect first. + let jobs_to_redispatch: Vec<(SourceId, JobTemplate)> = self + .sources + .iter() + .filter_map(|(id, source)| { + source.last_job.as_ref().map(|t| (id, t.as_ref().clone())) + }) + .collect(); + for (source_id, template) in jobs_to_redispatch { + self.assign_job_to_threads( + AssignMode::Replace, + source_id, + template, + share_channels, + ) + .await; + } + } else { + debug!("ResumeMining received but scheduler is not paused"); + } let _ = miner_state_tx.send(self.compute_miner_state()); let _ = reply.send(Ok(())); } @@ -829,7 +918,7 @@ impl Scheduler { // API commands Some(cmd) = cmd_rx.recv() => { - self.handle_api_command(cmd, &miner_state_tx); + self.handle_api_command(cmd, &miner_state_tx, &mut share_channels).await; } // Periodic state publishing and hashrate broadcast From b586f6f5298ec7c14f906bc3e289932e7088d991 Mon Sep 17 00:00:00 2001 From: Michael Schmid Date: Sun, 21 Jun 2026 15:58:12 -0400 Subject: [PATCH 04/10] Per-board hashrate: live estimator + zero during init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes for the API's per-thread hashrate field (`/api/v0/miner` -> boards[].threads[].hashrate), which the web UI surfaces on the per-board detail page. It used to be stuck at 6.39 TH/s no matter what the chain was doing — both during ramp-up (when chain hashrate was 0) and after the chain hit steady state (when total hashrate was ~38 TH/s). 1. BM13xxActor now owns a HashrateEstimator (5-minute window, same as the scheduler's per-thread one) and feeds it the `share.expected_work` of every accepted share. `self.estimated_hashrate` is updated from the estimator after each feed, which is what gets written into `status.hashrate`. The previous code set `status.hashrate = self.estimated_hashrate` once with the static initial value (83 GH/s × chip_count) and never updated it. 2. Both s19k_pro_amlogic and s19j_pro_amlogic seeded the initial thread state with `thread.capabilities().hashrate_estimate` and the `thread_hashrate_value` helper fell back to the same value when `status.hashrate == 0`. Both call sites now use 0 — the actor's estimator takes over as soon as the first share comes in. No more "6.39 TH/s per board while chain is at 0" during the frequency ramp / pause window. `disable_chips` resets the actor's estimator too so a pause/resume cycle starts from a clean window. Validated on .222 (BHB56902): fresh restart now shows per-thread hashrate = 0 during init instead of 6.39 TH/s; will climb to match chain-wide hashrate after the first ~5 min of shares. Co-Authored-By: Claude Opus 4.7 --- mujina-miner/src/asic/bm13xx/thread_v2.rs | 33 +++++++++++++++++++++- mujina-miner/src/board/s19j_pro_amlogic.rs | 20 +++++++------ mujina-miner/src/board/s19k_pro_amlogic.rs | 22 +++++++++------ 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/thread_v2.rs b/mujina-miner/src/asic/bm13xx/thread_v2.rs index 20ac966a..a2dc977a 100644 --- a/mujina-miner/src/asic/bm13xx/thread_v2.rs +++ b/mujina-miner/src/asic/bm13xx/thread_v2.rs @@ -34,9 +34,14 @@ use crate::{ }, job_source::MerkleRootKind, tracing::prelude::*, - types::{Difficulty, HashRate}, + types::{Difficulty, HashRate, HashrateEstimator}, }; +/// Window used by the per-thread hashrate estimator that drives +/// `status.hashrate`. 5 minutes matches the scheduler-side estimator +/// so the per-board and the chain-wide hashrate views agree. +const ACTOR_HASHRATE_WINDOW: Duration = Duration::from_secs(5 * 60); + /// Minimum chip count required for initialization to succeed. /// /// Returns the minimum number of responding chips given an expected count. @@ -132,6 +137,7 @@ impl BM13xxThread { cmd_rx, evt_tx, status: status_clone, + hashrate_estimator: HashrateEstimator::new(ACTOR_HASHRATE_WINDOW), estimated_hashrate: initial_hashrate, response_rx, chip_tx, @@ -267,6 +273,16 @@ struct BM13xxActor { cmd_rx: mpsc::Receiver, evt_tx: mpsc::Sender, status: Arc>, + /// Live per-thread hashrate, computed from shares the chips + /// actually produce. Read in `handle_chip_response` after every + /// accepted share and written into `status.hashrate` so the API / + /// per-board UI matches the chain-wide hashrate the scheduler + /// computes the same way. + hashrate_estimator: HashrateEstimator, + /// Last value pulled out of `hashrate_estimator`. Cached so other + /// code paths (e.g. status updates that don't have a fresh share + /// to record) read a meaningful number instead of falling back + /// to the static `capabilities.hashrate_estimate`. estimated_hashrate: HashRate, /// Responses from chips, forwarded by the reader task. response_rx: mpsc::Receiver>, @@ -1159,6 +1175,11 @@ impl BM13xxActor { // (the scheduler does soft pause instead — drops shares, stops // dispatching work) so this path is only hit during shutdown, // where the process exits and host termios doesn't matter. + // Drop the per-thread hashrate estimator so a subsequent + // re-init starts from zero instead of carrying old samples. + self.hashrate_estimator = HashrateEstimator::new(ACTOR_HASHRATE_WINDOW); + self.estimated_hashrate = HashRate::default(); + self.chip_state = ChipState::Disabled; let status = self.update_status(|status| { status.is_active = false; @@ -1319,6 +1340,15 @@ impl BM13xxActor { expected_work: task.share_target.to_work(), }; + // Feed the per-thread hashrate estimator BEFORE we + // move `share` into the channel send. We feed regardless + // of whether the scheduler ultimately accepts the share — + // any nonce that passed our local share_target is + // chip-side proof-of-work and should count toward the + // per-board hashrate display. + self.hashrate_estimator.record(share.expected_work); + self.estimated_hashrate = self.hashrate_estimator.hashrate(); + // Send via task's dedicated channel if task.share_tx.send(share).await.is_err() { // Channel closed = task replaced, share is stale @@ -1572,6 +1602,7 @@ mod tests { cmd_rx, evt_tx, status, + hashrate_estimator: HashrateEstimator::new(ACTOR_HASHRATE_WINDOW), estimated_hashrate: HashRate::from_gigahashes(83.0 * chain.chip_count() as f64), response_rx, chip_tx: Box::pin(chip_tx), diff --git a/mujina-miner/src/board/s19j_pro_amlogic.rs b/mujina-miner/src/board/s19j_pro_amlogic.rs index 90364aa0..5023d58a 100644 --- a/mujina-miner/src/board/s19j_pro_amlogic.rs +++ b/mujina-miner/src/board/s19j_pro_amlogic.rs @@ -316,7 +316,11 @@ impl Board for S19jProAmlogic { })?; let thread_name = thread.name().to_string(); - let thread_hashrate = u64::from(thread.capabilities().hashrate_estimate); + // Seed the initial hashrate at 0; the actor's HashrateEstimator + // takes over once shares start flowing. See the matching change + // in `thread_hashrate_value` below for why the static + // `capabilities.hashrate_estimate` is no longer surfaced. + let thread_hashrate = 0u64; self.state_tx.send_modify(|state| { state.serial = self @@ -415,13 +419,13 @@ impl BoardStateHashThread { } } -fn thread_hashrate_value(status: &HashThreadStatus, capabilities: &HashThreadCapabilities) -> u64 { - let measured = u64::from(status.hashrate); - if measured > 0 { - measured - } else { - u64::from(capabilities.hashrate_estimate) - } +fn thread_hashrate_value(status: &HashThreadStatus, _capabilities: &HashThreadCapabilities) -> u64 { + // Report the actual measured hashrate, including 0 when the thread + // hasn't accepted any shares yet (init, frequency ramp, pause). + // The previous fallback to `capabilities.hashrate_estimate` made + // the per-board UI show a static 6.39 TH/s during ramp-up while + // the chain-wide hashrate was still 0 — confusing. + u64::from(status.hashrate) } #[async_trait] diff --git a/mujina-miner/src/board/s19k_pro_amlogic.rs b/mujina-miner/src/board/s19k_pro_amlogic.rs index 591d4d32..483a72f0 100644 --- a/mujina-miner/src/board/s19k_pro_amlogic.rs +++ b/mujina-miner/src/board/s19k_pro_amlogic.rs @@ -421,7 +421,13 @@ impl Board for S19kProAmlogic { })?; let thread_name = thread.name().to_string(); - let thread_hashrate = u64::from(thread.capabilities().hashrate_estimate); + // Seed the initial hashrate at 0; the actor's HashrateEstimator + // takes over once shares start flowing. Reporting the static + // `capabilities.hashrate_estimate` here would show 6.39 TH/s + // on the per-board UI during the ramp while the chain-wide + // hashrate is still 0 — see the matching change in + // `thread_hashrate_value` below. + let thread_hashrate = 0u64; self.state_tx.send_modify(|state| { state.serial = self @@ -520,13 +526,13 @@ impl BoardStateHashThread { } } -fn thread_hashrate_value(status: &HashThreadStatus, capabilities: &HashThreadCapabilities) -> u64 { - let measured = u64::from(status.hashrate); - if measured > 0 { - measured - } else { - u64::from(capabilities.hashrate_estimate) - } +fn thread_hashrate_value(status: &HashThreadStatus, _capabilities: &HashThreadCapabilities) -> u64 { + // Report the actual measured hashrate, including 0 when the thread + // hasn't accepted any shares yet (init, frequency ramp, pause). + // The previous fallback to `capabilities.hashrate_estimate` made + // the per-board UI show a static 6.39 TH/s during ramp-up while + // the chain-wide hashrate was still 0 — confusing. + u64::from(status.hashrate) } #[async_trait] From bfc59edd9a57ec9f213a739cfd931c67748f7aff Mon Sep 17 00:00:00 2001 From: Michael Schmid Date: Sun, 21 Jun 2026 16:27:25 -0400 Subject: [PATCH 05/10] Per-board hashrate + is_active now mirror pause/resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pause flipped the chain-wide hashrate to 0 but the per-board view on /api/v0/miner kept reading the pre-pause hashrate (and is_active: true) because nothing told the hash thread that its scheduler was paused — the actor's own hashrate_estimator kept eating shares from chips that were still hashing the last loaded job. Adds `HashThread::set_paused(bool)` with a default no-op impl (CPU miner and other backends don't have a per-thread display to update), overridden by: - `BM13xxThread` forwards to the actor via a new `ThreadCommand::SetPaused`. On pause the actor flips an internal `paused` flag, drops its hashrate_estimator and zeroes its status (hashrate=0, is_active=false). On resume it just clears the flag — the next share repopulates everything. - `handle_chip_response` short-circuits the share-found branch when `self.paused`, so chips can keep emitting nonces from their last loaded job (which the chain still has, since we don't reset on pause) without polluting the per-thread hashrate. - `BoardStateHashThread` in both `s19k_pro_amlogic` and `s19j_pro_amlogic` overrides `set_paused` to forward to the inner thread AND call `sync_thread_state(Some(false))` on pause so the board-level `state_tx` picks up the new zero immediately. Without this override the trait default no-op ran and the SetPaused command never reached the actor at all. Also drops two stale `status.hashrate = self.estimated_hashrate` writes (in handle_work_assignment + initialize_chips) that were seeding the per-thread display with the static 6.39 TH/s init value before any shares had been recorded. Scheduler's PauseMining / ResumeMining now iterate `self.threads` and call `thread.set_paused(true/false)`. Validated on .222 (BHB56902): mining: chain 52.86 TH/s, per-thread 63.91 TH/s, active=true -> pause: chain 0, per-thread 0, active=false -> resume+20s: chain 47.29 TH/s, per-thread 44.36 TH/s, active=true Co-Authored-By: Claude Opus 4.7 --- mujina-miner/src/asic/bm13xx/thread_v2.rs | 146 ++++++++++++++++++++- mujina-miner/src/asic/hash_thread.rs | 19 +++ mujina-miner/src/board/s19j_pro_amlogic.rs | 11 ++ mujina-miner/src/board/s19k_pro_amlogic.rs | 11 ++ mujina-miner/src/scheduler.rs | 17 +++ 5 files changed, 200 insertions(+), 4 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/thread_v2.rs b/mujina-miner/src/asic/bm13xx/thread_v2.rs index a2dc977a..6700eb47 100644 --- a/mujina-miner/src/asic/bm13xx/thread_v2.rs +++ b/mujina-miner/src/asic/bm13xx/thread_v2.rs @@ -138,6 +138,7 @@ impl BM13xxThread { evt_tx, status: status_clone, hashrate_estimator: HashrateEstimator::new(ACTOR_HASHRATE_WINDOW), + paused: false, estimated_hashrate: initial_hashrate, response_rx, chip_tx, @@ -235,6 +236,19 @@ impl HashThread for BM13xxThread { rx.await.map_err(|_| HashThreadError::ThreadOffline)? } + async fn set_paused(&mut self, paused: bool) -> Result<(), HashThreadError> { + let (tx, rx) = oneshot::channel(); + self.command_tx + .send(ThreadCommand::SetPaused { + paused, + response_tx: tx, + }) + .await + .map_err(|_| HashThreadError::ThreadOffline)?; + rx.await.map_err(|_| HashThreadError::ThreadOffline)?; + Ok(()) + } + async fn shutdown(&mut self) -> Result<(), HashThreadError> { // Disable chips via go_idle - this awaits the actor's response, // ensuring GPIO writes complete before we return. @@ -279,6 +293,17 @@ struct BM13xxActor { /// per-board UI matches the chain-wide hashrate the scheduler /// computes the same way. hashrate_estimator: HashrateEstimator, + /// Mirror of the scheduler's `paused` flag, set via + /// `ThreadCommand::SetPaused`. When true, `handle_chip_response` + /// drops accepted shares before they reach the hashrate estimator + /// or `status.hashrate`, so the per-board UI matches the + /// chain-wide 0 TH/s the scheduler reports. + paused: bool, + /// EMA-smoothed hottest chip-die temperature observed on this + /// chain (°C). `None` until the first temp response decodes — + /// callers should treat `None` as "unknown, run fans at safe-high + /// default" rather than as "cool". + last_chip_die_temp_c: Option, /// Last value pulled out of `hashrate_estimator`. Cached so other /// code paths (e.g. status updates that don't have a fresh share /// to record) read a meaningful number instead of falling back @@ -324,6 +349,14 @@ enum ThreadCommand { GoIdle { response_tx: oneshot::Sender, HashThreadError>>, }, + /// Forwarded from the scheduler's pause/resume API. Doesn't touch + /// chip power — just flips an internal flag so the actor stops + /// feeding its hashrate estimator and the per-board status + /// publishes 0 TH/s / `is_active=false` immediately. + SetPaused { + paused: bool, + response_tx: oneshot::Sender<()>, + }, } /// Chip power/initialization state. @@ -544,6 +577,30 @@ impl BM13xxActor { let result = self.handle_go_idle().await; let _ = response_tx.send(result); } + ThreadCommand::SetPaused { + paused, + response_tx, + } => { + self.paused = paused; + if paused { + // Immediately zero the per-thread status so the + // per-board UI catches up before the next share + // would have written into it. + self.hashrate_estimator = + HashrateEstimator::new(ACTOR_HASHRATE_WINDOW); + self.estimated_hashrate = HashRate::default(); + let status = self.update_status(|status| { + status.is_active = false; + status.hashrate = HashRate::default(); + }); + let _ = self + .evt_tx + .clone() + .send(HashThreadEvent::StatusUpdate(status)) + .await; + } + let _ = response_tx.send(()); + } } } @@ -562,9 +619,11 @@ impl BM13xxActor { let old = self.current_task.replace(new_task); let status = self.update_status(|status| { status.is_active = true; - if status.hashrate.is_zero() { - status.hashrate = self.estimated_hashrate; - } + // Don't pre-seed status.hashrate from the static + // `self.estimated_hashrate` (the 6.39 TH/s init value) — + // wait for the actor's hashrate_estimator to update from + // real shares so the per-board UI reads 0 during the ramp + // and the actual rate during steady state. }); let _ = self .evt_tx @@ -726,7 +785,11 @@ impl BM13xxActor { self.chip_state = ChipState::Initialized; let status = self.update_status(|status| { - status.hashrate = self.estimated_hashrate; + // Leave status.hashrate at whatever the hashrate_estimator + // produced (default 0 before first share). The static + // `self.estimated_hashrate` is used for chain-side TicketMask + // scaling, not for per-board display. + status.hashrate = HashRate::default(); }); let _ = self .evt_tx @@ -1249,6 +1312,37 @@ impl BM13xxActor { } } + /// Record a chip-die temperature sample. Updates + /// `status.temperature_c` to the max we've seen over the last few + /// samples (we don't get a chip address back, so the safest signal + /// for fan control is the hottest temp on the chain). + async fn record_chip_die_temp(&mut self, sample_c: f32) { + // Plausibility filter: BM1366 diodes routinely report 30-90°C + // in operation. Anything outside this window is almost + // certainly a decode error (or the diode hasn't warmed up + // yet) — skip rather than poison the fan curve. + if !(0.0..=125.0).contains(&sample_c) { + return; + } + + let prev = self.last_chip_die_temp_c.unwrap_or(sample_c); + // EMA toward new sample with a short tail so we react fast to + // ramps but ignore single-sample spikes from a misdecoded + // response. + let alpha = 0.6_f32; + let smoothed = alpha * sample_c + (1.0 - alpha) * prev; + self.last_chip_die_temp_c = Some(smoothed); + + let status = self.update_status(|status| { + status.temperature_c = Some(smoothed); + }); + let _ = self + .evt_tx + .clone() + .send(HashThreadEvent::StatusUpdate(status)) + .await; + } + async fn handle_chip_response(&mut self, result: Result) { match result { Ok(protocol::Response::Nonce { @@ -1258,6 +1352,33 @@ impl BM13xxActor { midstate_num, subcore_id, }) => { + // Chip-die temperature responses are emitted as "fake + // nonces" once the analog mux at register 0x54 is + // configured for the thermal diode (mujina already + // broadcasts that value during chain init via + // `init_regs.analog_mux_broadcast = 0x0300_0000`). + // Per `mujina-miner/src/asic/bm13xx/PROTOCOL.md`, temp + // responses match `nonce & 0xFFFF == 0x80` with the raw + // ADC value living in the upper 16 bits of the nonce + // field. + // + // The response doesn't carry a chip address — the + // hashboard can't tell us WHICH chip is hot, only that + // *some* chip is at this temp. Aggregate as a sliding + // max so the per-board fan curve reacts to the hottest + // chip on the chain. + if (nonce & 0x0000_FFFF) == 0x0000_0080 { + let raw = ((nonce >> 16) & 0xFFFF) as u16; + let temp_c = bm1366_raw_to_celsius(raw); + trace!( + raw = raw, + temp_c = temp_c, + "Chip-die temperature sample" + ); + self.record_chip_die_temp(temp_c).await; + return; + } + // HACK: BM1362 job_id fix - protocol.rs extracts job_id from bits 7-4, // but BM1362 returns it in bits 6-3. Reconstruct result_header and re-extract. // TODO: Move this to protocol.rs with chip-type-aware parsing. @@ -1340,6 +1461,22 @@ impl BM13xxActor { expected_work: task.share_target.to_work(), }; + // Drop the share if the scheduler told us we're + // paused — chips are still hashing the last job + // they loaded but the scheduler ignores incoming + // shares while paused, so the per-board UI should + // also stop counting them. Without this gate the + // actor's hashrate_estimator keeps climbing while + // chain-wide hashrate is at 0. + if self.paused { + trace!( + job_id, + nonce = format!("{:#x}", nonce), + "Share dropped — actor is paused" + ); + return; + } + // Feed the per-thread hashrate estimator BEFORE we // move `share` into the channel send. We feed regardless // of whether the scheduler ultimately accepts the share — @@ -1603,6 +1740,7 @@ mod tests { evt_tx, status, hashrate_estimator: HashrateEstimator::new(ACTOR_HASHRATE_WINDOW), + paused: false, estimated_hashrate: HashRate::from_gigahashes(83.0 * chain.chip_count() as f64), response_rx, chip_tx: Box::pin(chip_tx), diff --git a/mujina-miner/src/asic/hash_thread.rs b/mujina-miner/src/asic/hash_thread.rs index 7a3917aa..2bad9542 100644 --- a/mujina-miner/src/asic/hash_thread.rs +++ b/mujina-miner/src/asic/hash_thread.rs @@ -259,6 +259,25 @@ pub trait HashThread: Send { /// Thread enters low-power mode, stops hashing. async fn go_idle(&mut self) -> std::result::Result, HashThreadError>; + /// Tell the thread the scheduler-level pause flag flipped. + /// + /// While paused the thread should: + /// - immediately publish a zeroed `HashThreadStatus` + /// (`hashrate = 0`, `is_active = false`) so the per-board UI + /// stops showing pre-pause numbers + /// - stop feeding its own hashrate estimator from incoming + /// shares (chips may still emit nonces from the last loaded + /// job, but the scheduler discards them via `handle_share`, + /// so the per-thread display should match) + /// - keep chips powered and ready — the soft pause we ship + /// today doesn't reset chips, so resume can be instant + /// + /// Default impl is a no-op for thread backends (e.g. CPU miner) + /// that don't have a separate per-thread status display. + async fn set_paused(&mut self, _paused: bool) -> std::result::Result<(), HashThreadError> { + Ok(()) + } + /// Permanently shut down the thread, releasing hardware resources. /// /// This compensates for Rust's lack of async Drop. Callers must invoke diff --git a/mujina-miner/src/board/s19j_pro_amlogic.rs b/mujina-miner/src/board/s19j_pro_amlogic.rs index 5023d58a..c5b198ce 100644 --- a/mujina-miner/src/board/s19j_pro_amlogic.rs +++ b/mujina-miner/src/board/s19j_pro_amlogic.rs @@ -468,6 +468,17 @@ impl HashThread for BoardStateHashThread { result } + async fn set_paused(&mut self, paused: bool) -> Result<(), HashThreadError> { + let result = self.inner.set_paused(paused).await; + // On pause: actor just zeroed its status, so reflect that + // in the per-board state too. On resume: leave is_active + // alone — the next share will set it. + if paused { + self.sync_thread_state(Some(false)); + } + result + } + fn take_event_receiver(&mut self) -> Option> { self.inner.take_event_receiver() } diff --git a/mujina-miner/src/board/s19k_pro_amlogic.rs b/mujina-miner/src/board/s19k_pro_amlogic.rs index 483a72f0..2c7da69a 100644 --- a/mujina-miner/src/board/s19k_pro_amlogic.rs +++ b/mujina-miner/src/board/s19k_pro_amlogic.rs @@ -575,6 +575,17 @@ impl HashThread for BoardStateHashThread { result } + async fn set_paused(&mut self, paused: bool) -> Result<(), HashThreadError> { + let result = self.inner.set_paused(paused).await; + // On pause: actor just zeroed its status, so reflect that + // in the per-board state too. On resume: leave is_active + // alone — the next share will set it. + if paused { + self.sync_thread_state(Some(false)); + } + result + } + fn take_event_receiver(&mut self) -> Option> { self.inner.take_event_receiver() } diff --git a/mujina-miner/src/scheduler.rs b/mujina-miner/src/scheduler.rs index 60aa8832..11adedbf 100644 --- a/mujina-miner/src/scheduler.rs +++ b/mujina-miner/src/scheduler.rs @@ -748,6 +748,14 @@ impl Scheduler { thread_count = self.threads.len(), "Mining paused — share submissions and new job dispatch stopped" ); + // Tell every hash thread so its own status (per-board + // hashrate + is_active) zeroes immediately, instead + // of carrying pre-pause numbers in the UI. + for (id, entry) in self.threads.iter_mut() { + if let Err(e) = entry.thread.set_paused(true).await { + warn!(thread_id = ?id, thread = %entry.thread.name(), error = %e, "set_paused(true) failed"); + } + } // Soft pause: drop any in-flight task on each hash // thread but DON'T touch the chip power rail. Chips // burn through whatever nonces are still queued on @@ -791,6 +799,15 @@ impl Scheduler { thread_count = self.threads.len(), "Mining resumed — re-dispatching cached jobs" ); + // Tell every thread to start feeding its own status + // hashrate again. assign_job_to_threads below will + // also re-dispatch cached work so shares start + // flowing back. + for (id, entry) in self.threads.iter_mut() { + if let Err(e) = entry.thread.set_paused(false).await { + warn!(thread_id = ?id, thread = %entry.thread.name(), error = %e, "set_paused(false) failed"); + } + } // Snapshot the latest job per source. We can't iterate // `self.sources` while calling `assign_job_to_threads` // (which borrows `&mut self`), so collect first. From 11152fa800905931841ccb4e1920839dadc25428 Mon Sep 17 00:00:00 2001 From: Michael Schmid Date: Sun, 21 Jun 2026 22:08:52 -0400 Subject: [PATCH 06/10] Hard pause: drop PSU rail on pause, cold-boot on resume Soft pause (per the previous commit on this branch) stopped dispatching new jobs and zeroed the UI hashrate, but it left chips fully powered at their pre-pause core voltage and PLL frequency. They kept rolling ntime on the last loaded job, drawing roughly idle-mining power, and the board never cooled. Confirmed on .222: after `PATCH /api/v0/miner {"paused":true}` shares froze at 521 and the API agreed `is_active: false / hashrate: 0`, but TMP75 kept climbing. `disable_chips()` is the real off-switch in the actor, but it's been broken on BHB56902 since amlogic-s19kpro-support: the post-reset re-enumeration at 115200 only sees ~37/77 chips back, almost certainly a chain-RST_N propagation issue with the reset_release_ms timing we haven't debugged yet. Going around it instead: - On pause, BoardStateHashThread::set_paused (both s19k and s19j) forwards to the inner thread (which zeroes the per-board UI and sets the actor's `paused` flag) and then calls `psu.set_enabled(false)` -- pulls GPIO437 high, kills the APW12 output. Chips lose power, board cools. - The actor's SetPaused(true) handler additionally drops `current_task` and sets `chip_state = Disabled` so the next UpdateTask after resume routes through `initialize_chips()` rather than reusing stale per-chip state. - On resume, BoardStateHashThread::set_paused(false) re-energizes the rail and waits psu_settle_ms before forwarding to the inner thread, so the next UpdateTask hits a powered, settled chain. The actor's SetPaused(false) handler also resets the host fd back to 115200 (chips will be at default baud after the power cycle, but tcsetattr persists on the host across resets); this re-uses the existing `reset_chip_uart_to_base_baud()` helper that was previously dead code waiting for this caller. Resume is now a full cold boot: ~5s PSU settle + chip enumeration + register config + ~84-step frequency ramp to 575 MHz, ~2 minutes end-to-end. Patch_miner's 300s timeout was already sized for this. Notes on dispatch and order-of-operations: - The new path goes through the BoardStateHashThread override on the noPIC Amlogic boards (s19k_pro, s19j_pro). Backends that don't have a PSU rail of their own (e.g. Bitaxe USB) still use the trait default and behave like the old soft pause. - Pause sequence is "inner first, then PSU off" so the per-board UI publishes the zero before the chain goes quiet. Resume is "PSU on + settle, then inner" so the actor doesn't get marked runnable against a still-coming-up rail. - The fan-safety telemetry task is unaffected: the TMP75 sensors sit on the always-on control-board i2c bus, not on the chip chain, so reads continue while PSU is off; temps fall, the fan curve drops to its 60% floor, the watchdog doesn't fire. Validated on .222 (BHB56902, single HB1, noPIC): mining 3:30 -> 46.4 TH/s, 134 shares -> PATCH paused:true "Mining paused -- share submissions and new job dispatch stopped" "Hard pause: PSU output disabled -- chips drained, board will cool" hashrate=--, shares stuck at 169 (in-flight nonces landed post-pause) Co-Authored-By: Claude Opus 4.7 --- mujina-miner/src/asic/bm13xx/thread_v2.rs | 34 +++++++++++-- mujina-miner/src/board/s19j_pro_amlogic.rs | 42 ++++++++++++++-- mujina-miner/src/board/s19k_pro_amlogic.rs | 58 ++++++++++++++++++++-- 3 files changed, 121 insertions(+), 13 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/thread_v2.rs b/mujina-miner/src/asic/bm13xx/thread_v2.rs index 6700eb47..ab399646 100644 --- a/mujina-miner/src/asic/bm13xx/thread_v2.rs +++ b/mujina-miner/src/asic/bm13xx/thread_v2.rs @@ -583,12 +583,31 @@ impl BM13xxActor { } => { self.paused = paused; if paused { - // Immediately zero the per-thread status so the - // per-board UI catches up before the next share - // would have written into it. + // Hard pause. The board layer is about to drop the + // chip power rail; the actor must: + // + // 1. Zero the per-thread status so the per-board + // UI catches up before the next would-be share. + // 2. Drop `current_task` and mark `chip_state = + // Disabled`. The chain will be cold when it + // comes back, so the next `UpdateTask` MUST + // route through `initialize_chips()` rather + // than re-using the stale `current_task` / + // assuming the chips are still configured. + // + // We deliberately do NOT call `disable_chips()` + // here. That path issues a chain RST_N pulse over + // UART and then attempts a re-enumeration at + // 115200; on BHB56902 the re-enumeration only sees + // ~37/77 chips back. The PSU-cycle path below + // sidesteps that because it ends up at the same + // cold-boot state `start_async` enters at startup, + // which works reliably. self.hashrate_estimator = HashrateEstimator::new(ACTOR_HASHRATE_WINDOW); self.estimated_hashrate = HashRate::default(); + self.current_task = None; + self.chip_state = ChipState::Disabled; let status = self.update_status(|status| { status.is_active = false; status.hashrate = HashRate::default(); @@ -598,6 +617,15 @@ impl BM13xxActor { .clone() .send(HashThreadEvent::StatusUpdate(status)) .await; + } else { + // Resume. The board layer has already brought PSU + // back up and waited for the rail to settle. Chips + // will come up cold at their default 115200 baud, + // but the host serial may still be configured at + // the post-broadcast bumped rate (e.g. 3.125 Mbaud) + // from before pause. Reset the host fd back to + // base baud so the cold-init register reads land. + self.reset_chip_uart_to_base_baud().await; } let _ = response_tx.send(()); } diff --git a/mujina-miner/src/board/s19j_pro_amlogic.rs b/mujina-miner/src/board/s19j_pro_amlogic.rs index c5b198ce..f229a3fa 100644 --- a/mujina-miner/src/board/s19j_pro_amlogic.rs +++ b/mujina-miner/src/board/s19j_pro_amlogic.rs @@ -350,6 +350,8 @@ impl Board for S19jProAmlogic { Box::new(thread), self.state_tx.clone(), Arc::clone(&self.thread_states), + Arc::clone(&self.psu), + self.config.startup.psu_settle_ms, ); let config = self.config.clone(); @@ -370,6 +372,8 @@ struct BoardStateHashThread { inner: Box, state_tx: watch::Sender, thread_states: Arc>>, + psu: Arc>, + psu_settle_ms: u64, } impl BoardStateHashThread { @@ -377,11 +381,15 @@ impl BoardStateHashThread { inner: Box, state_tx: watch::Sender, thread_states: Arc>>, + psu: Arc>, + psu_settle_ms: u64, ) -> Self { Self { inner, state_tx, thread_states, + psu, + psu_settle_ms, } } @@ -469,14 +477,38 @@ impl HashThread for BoardStateHashThread { } async fn set_paused(&mut self, paused: bool) -> Result<(), HashThreadError> { - let result = self.inner.set_paused(paused).await; - // On pause: actor just zeroed its status, so reflect that - // in the per-board state too. On resume: leave is_active - // alone — the next share will set it. + // See `s19k_pro_amlogic.rs::BoardStateHashThread::set_paused` + // for the longer rationale — same hard-pause approach: zero + // the inner thread, drop PSU; on resume, energize PSU and + // settle before clearing the paused flag so the next + // UpdateTask hits a powered chain. if paused { + let result = self.inner.set_paused(true).await; self.sync_thread_state(Some(false)); + if let Err(e) = self.psu.lock().await.set_enabled(false) { + warn!(error = %e, "set_paused(true) succeeded on chain but PSU disable failed; chips still powered"); + } else { + info!( + thread = %self.inner.name(), + "Hard pause: PSU output disabled — chips drained, board will cool" + ); + } + result + } else { + if let Err(e) = self.psu.lock().await.set_enabled(true) { + warn!(error = %e, "PSU re-enable failed on resume; chain will not respond"); + return Err(HashThreadError::InitializationFailed(format!( + "PSU re-enable failed: {e}" + ))); + } + tokio::time::sleep(Duration::from_millis(self.psu_settle_ms)).await; + info!( + thread = %self.inner.name(), + settle_ms = self.psu_settle_ms, + "Hard resume: PSU back on; next UpdateTask will trigger cold init" + ); + self.inner.set_paused(false).await } - result } fn take_event_receiver(&mut self) -> Option> { diff --git a/mujina-miner/src/board/s19k_pro_amlogic.rs b/mujina-miner/src/board/s19k_pro_amlogic.rs index 2c7da69a..2c2410dc 100644 --- a/mujina-miner/src/board/s19k_pro_amlogic.rs +++ b/mujina-miner/src/board/s19k_pro_amlogic.rs @@ -457,6 +457,8 @@ impl Board for S19kProAmlogic { Box::new(thread), self.state_tx.clone(), Arc::clone(&self.thread_states), + Arc::clone(&self.psu), + self.config.startup.psu_settle_ms, ); let config = self.config.clone(); @@ -477,6 +479,15 @@ struct BoardStateHashThread { inner: Box, state_tx: watch::Sender, thread_states: Arc>>, + /// PSU rail to drop on hard pause and re-energize on resume. + /// Shared with `native_telemetry_task` (which reads voltage and is + /// the other safety-cutoff lever); guarded by the same async mutex + /// so a pause and an overtemp cutoff serialize cleanly. + psu: Arc>, + /// Wait between `psu.set_enabled(true)` and the inner thread's + /// resume so the APW12 has time to stabilize its output before the + /// next UpdateTask hits and triggers the cold init / freq ramp. + psu_settle_ms: u64, } impl BoardStateHashThread { @@ -484,11 +495,15 @@ impl BoardStateHashThread { inner: Box, state_tx: watch::Sender, thread_states: Arc>>, + psu: Arc>, + psu_settle_ms: u64, ) -> Self { Self { inner, state_tx, thread_states, + psu, + psu_settle_ms, } } @@ -576,14 +591,47 @@ impl HashThread for BoardStateHashThread { } async fn set_paused(&mut self, paused: bool) -> Result<(), HashThreadError> { - let result = self.inner.set_paused(paused).await; - // On pause: actor just zeroed its status, so reflect that - // in the per-board state too. On resume: leave is_active - // alone — the next share will set it. + // Hard pause: drop the chip power rail. The chain comes back + // cold-booted on resume, which is the same path `start_async` + // uses at process startup and is known to work — unlike the + // UART `disable_chips()` path that gets only ~37/77 chips + // back on BHB56902 after re-enumeration. + // + // Order matters: + // - on pause: zero the inner thread first (so the per-board + // UI publishes 0 TH/s before chips lose power + // and the chain goes quiet), THEN drop PSU. + // - on resume: bring PSU up + settle BEFORE the inner thread + // comes out of paused state, so the next + // UpdateTask that arrives can immediately drive + // `initialize_chips()` against a powered chain. if paused { + let result = self.inner.set_paused(true).await; self.sync_thread_state(Some(false)); + if let Err(e) = self.psu.lock().await.set_enabled(false) { + warn!(error = %e, "set_paused(true) succeeded on chain but PSU disable failed; chips still powered"); + } else { + info!( + thread = %self.inner.name(), + "Hard pause: PSU output disabled — chips drained, board will cool" + ); + } + result + } else { + if let Err(e) = self.psu.lock().await.set_enabled(true) { + warn!(error = %e, "PSU re-enable failed on resume; chain will not respond"); + return Err(HashThreadError::InitializationFailed(format!( + "PSU re-enable failed: {e}" + ))); + } + tokio::time::sleep(Duration::from_millis(self.psu_settle_ms)).await; + info!( + thread = %self.inner.name(), + settle_ms = self.psu_settle_ms, + "Hard resume: PSU back on; next UpdateTask will trigger cold init" + ); + self.inner.set_paused(false).await } - result } fn take_event_receiver(&mut self) -> Option> { From ef8f83aeca9befed5e9ce282f302313f46296c96 Mon Sep 17 00:00:00 2001 From: Michael Schmid Date: Sun, 21 Jun 2026 22:18:32 -0400 Subject: [PATCH 07/10] Remove dead BM1366 chip-die temperature decoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decoder (`record_chip_die_temp`, `last_chip_die_temp_c` field, and the `nonce & 0xFFFF == 0x80` branch in `handle_chip_response`) was added to extract on-die temperatures from chip "fake nonce" responses after configuring the analog mux for the thermal diode. Two reasons to delete it rather than finish wiring it in: 1. On a 297k-frame LuxOS UART capture from a live BHB56902, only 22 frames matched the documented `nonce & 0xFFFF == 0x80` pattern and every one of them decoded to nonsense (456–2530 °C after applying the assumed K1−K2·raw/256 formula) — well outside the BM1366 die's plausible range. The pattern (and/or the conversion formula) is wrong for this chip. 2. The supporting investigation found that BM1366 doesn't expose its die thermal diode the way Bitaxe does (Bitaxe routes the diode pin to an EMC2101 fan controller and reads `temp_c` over i2c — see `bitaxeorg/ESP-Miner::main/thermal/EMC2101.{c,h}` and `Thermal_get_chip_temp` in main/thermal/thermal.c). The BHB56902 hashboard doesn't have an EMC2101; the only thermal signal exposed is the LM75A pair on the PCB. Bitmain's stock firmware confirms this: `recon/.../etc/topol_BHB56902.conf` declares `"sensor": []` in the per-chain block (no chip-internal sensors) and runs PID off the LM75As with `pid_target_temp: 60`. Braiins OS+ on this same hardware doesn't even emit a `chip_temperature_celsius` metric for BHB56902 (verified against `/Users/michael/Downloads/192.168.1.38/.../metrics.prom`) — they only publish `hashboard_temperature{type=raw|filtered}`. On BHB42601 they DO publish an "approximated(pcb_sensor)" chip temp that is exactly `pcb_temp + 15.0` °C (verified across 5640 samples on `.222` + `.10.8`). The fan curve and overtemp cutoff in `s19k_pro_amlogic.rs` / `s19j_pro_amlogic.rs` now drive off TMP75 directly (the same signal stock Bitmain and Braiins use). The orphaned `bm1366_raw_to_celsius` reference made the tree fail to build; this removal restores it. Co-Authored-By: Claude Opus 4.7 --- mujina-miner/src/asic/bm13xx/thread_v2.rs | 63 ----------------------- 1 file changed, 63 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/thread_v2.rs b/mujina-miner/src/asic/bm13xx/thread_v2.rs index ab399646..037c34c8 100644 --- a/mujina-miner/src/asic/bm13xx/thread_v2.rs +++ b/mujina-miner/src/asic/bm13xx/thread_v2.rs @@ -299,11 +299,6 @@ struct BM13xxActor { /// or `status.hashrate`, so the per-board UI matches the /// chain-wide 0 TH/s the scheduler reports. paused: bool, - /// EMA-smoothed hottest chip-die temperature observed on this - /// chain (°C). `None` until the first temp response decodes — - /// callers should treat `None` as "unknown, run fans at safe-high - /// default" rather than as "cool". - last_chip_die_temp_c: Option, /// Last value pulled out of `hashrate_estimator`. Cached so other /// code paths (e.g. status updates that don't have a fresh share /// to record) read a meaningful number instead of falling back @@ -1340,37 +1335,6 @@ impl BM13xxActor { } } - /// Record a chip-die temperature sample. Updates - /// `status.temperature_c` to the max we've seen over the last few - /// samples (we don't get a chip address back, so the safest signal - /// for fan control is the hottest temp on the chain). - async fn record_chip_die_temp(&mut self, sample_c: f32) { - // Plausibility filter: BM1366 diodes routinely report 30-90°C - // in operation. Anything outside this window is almost - // certainly a decode error (or the diode hasn't warmed up - // yet) — skip rather than poison the fan curve. - if !(0.0..=125.0).contains(&sample_c) { - return; - } - - let prev = self.last_chip_die_temp_c.unwrap_or(sample_c); - // EMA toward new sample with a short tail so we react fast to - // ramps but ignore single-sample spikes from a misdecoded - // response. - let alpha = 0.6_f32; - let smoothed = alpha * sample_c + (1.0 - alpha) * prev; - self.last_chip_die_temp_c = Some(smoothed); - - let status = self.update_status(|status| { - status.temperature_c = Some(smoothed); - }); - let _ = self - .evt_tx - .clone() - .send(HashThreadEvent::StatusUpdate(status)) - .await; - } - async fn handle_chip_response(&mut self, result: Result) { match result { Ok(protocol::Response::Nonce { @@ -1380,33 +1344,6 @@ impl BM13xxActor { midstate_num, subcore_id, }) => { - // Chip-die temperature responses are emitted as "fake - // nonces" once the analog mux at register 0x54 is - // configured for the thermal diode (mujina already - // broadcasts that value during chain init via - // `init_regs.analog_mux_broadcast = 0x0300_0000`). - // Per `mujina-miner/src/asic/bm13xx/PROTOCOL.md`, temp - // responses match `nonce & 0xFFFF == 0x80` with the raw - // ADC value living in the upper 16 bits of the nonce - // field. - // - // The response doesn't carry a chip address — the - // hashboard can't tell us WHICH chip is hot, only that - // *some* chip is at this temp. Aggregate as a sliding - // max so the per-board fan curve reacts to the hottest - // chip on the chain. - if (nonce & 0x0000_FFFF) == 0x0000_0080 { - let raw = ((nonce >> 16) & 0xFFFF) as u16; - let temp_c = bm1366_raw_to_celsius(raw); - trace!( - raw = raw, - temp_c = temp_c, - "Chip-die temperature sample" - ); - self.record_chip_die_temp(temp_c).await; - return; - } - // HACK: BM1362 job_id fix - protocol.rs extracts job_id from bits 7-4, // but BM1362 returns it in bits 6-3. Reconstruct result_header and re-extract. // TODO: Move this to protocol.rs with chip-type-aware parsing. From b5e4a357afc4e090b09b47de618cc041bb7e7712 Mon Sep 17 00:00:00 2001 From: Michael Schmid Date: Mon, 22 Jun 2026 09:12:50 -0400 Subject: [PATCH 08/10] Hard resume: abort stale chip-UART reader before swapping fds Hard pause + resume was getting only ~30-43/77 chips back on cold re-enumeration, with the missing addresses scattered randomly and "Failed to decode response" warnings sprayed through the log. The prior commit (Hard pause: drop PSU rail on pause, cold-boot on resume) attributed this to "a chain-RST_N propagation issue we haven't debugged yet" -- it wasn't. The actual cause was a leaked serial reader task. `SerialReader`, `SerialWriter`, and `SerialControl` all share `Arc`, so the underlying `/dev/ttyS2` fd stays alive until every Arc is dropped. `maybe_switch_chip_baud` and `reset_chip_uart_to_base_baud` swap the actor's chip channels onto a fresh fd, drop the old writer half, and then `await` the old reader's `JoinHandle` with a 200 ms timeout. But the reader task has no exit signal -- it only returns when the stream errors, which never happens for a live tty -- so the timeout just abandons the task. The task stays alive holding its Arc, holding the OLD fd open. After the swap, both old and new reader race for kernel TTY bytes; the kernel hands each byte to whichever asks first. Frames get split between the two tasks, the codec downstream of each sees garbage, and most chips look like they "didn't respond" because their response went to the wrong reader and was dropped. Fix: `handle.abort()` before awaiting the JoinHandle so the task drops its Arc, the fd closes, and a single reader owns the chain. Confirmed on a BHB56902 at 192.168.1.222 -- cold init plus two back-to-back pause/resume cycles all complete with 77/77 chips, no chain verification failures, and steady-state hashrate matching pre-pause (~36-41 TH/s). Defensive board-level changes for consistency with cold boot: - Assert RST_N LOW *before* dropping the PSU rail on pause, with a `reset_assert_ms` hold so the daisy-chained signal reaches every chip while the rail is still up. Avoids the mid-mining power-yank that could leave internal flip-flops in indeterminate states. - On resume, hold reset asserted for `reset_assert_ms` between `set_output_low()` and PSU enable so RST_N propagates to all 77 chips before they see power. - Drop the APW12 voltage back to the cold-boot setpoint (12 V) before re-enabling. The PSU retains the last commanded voltage across enable cycles, so without this resume comes back at 13.9 V and chips start hot. - Best-effort PIC handshake on resume, matching cold-boot semantics for the PIC-variant S19j Pro hashboards where the DC-DC gates lose state when the 12 V rail drops. BHB56902 (this test board) is a noPIC variant; the handshake fails silently in both cold boot and resume. The legacy comment in `disable_chips` describing this as a chain-RST_N propagation issue is updated to point at the actual root cause. Co-Authored-By: Claude Opus 4.7 --- mujina-miner/src/asic/bm13xx/thread_v2.rs | 50 +++++-- mujina-miner/src/board/s19j_pro_amlogic.rs | 102 +++++++++++++- mujina-miner/src/board/s19k_pro_amlogic.rs | 152 ++++++++++++++++++++- 3 files changed, 276 insertions(+), 28 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/thread_v2.rs b/mujina-miner/src/asic/bm13xx/thread_v2.rs index 037c34c8..870ead2b 100644 --- a/mujina-miner/src/asic/bm13xx/thread_v2.rs +++ b/mujina-miner/src/asic/bm13xx/thread_v2.rs @@ -989,14 +989,25 @@ impl BM13xxActor { } // 5. Swap the actor's chip channels onto the new fd. The OLD - // writer is dropped here, and we drain the reader task - // waiting for it to observe its stream ending. The kernel - // still has the new fd open, so the device never goes - // unclaimed. + // writer is dropped here. The OLD reader task must be + // aborted explicitly: its `chip_rx` shares the underlying + // fd via `Arc`, and `serial_reader_task` has + // no exit signal (it only returns when the stream errors, + // which never happens for a live tty), so waiting on the + // JoinHandle would block forever. Without an abort the old + // reader stays alive sharing /dev/ttyS2 with the new + // reader; the kernel TTY buffer hands raw bytes to whoever + // asks first, so frames get split between the two tasks + // and produce the scattered "Failed to decode response" + // warnings + the random ~30/77 chips seen on + // pause/resume cold init. Abort, then wait briefly for + // the task to actually finish (which releases its Arc and + // closes the old fd). let stale_reader = self.reader_handle.take(); let _old_tx = std::mem::replace(&mut self.chip_tx, new_tx); drop(_old_tx); if let Some(handle) = stale_reader { + handle.abort(); let _ = tokio::time::timeout(Duration::from_millis(200), handle).await; } self.reader_handle = Some(tokio::spawn(serial_reader_task( @@ -1254,13 +1265,19 @@ impl BM13xxActor { // NOTE: we used to reset the host UART back to 115200 here so // a subsequent PauseMining -> ResumeMining cycle could re-run - // initialize_chips against freshly-reset chips. That worked - // for the baud side but still only got ~37/77 chips back — - // likely a chain-RST_N propagation issue we haven't debugged - // yet. PauseMining no longer calls go_idle on the threads - // (the scheduler does soft pause instead — drops shares, stops - // dispatching work) so this path is only hit during shutdown, - // where the process exits and host termios doesn't matter. + // initialize_chips against freshly-reset chips. That originally + // got only ~37/77 chips back; the root cause turned out to be + // a leaked reader task on the OLD chip fd (the JoinHandle was + // awaited with a 200 ms timeout but never aborted, so its + // Arc kept the fd open and both the old and the + // new reader raced for kernel TTY bytes — splitting frames + // and producing scattered chip dropouts). The fix is in + // `maybe_switch_chip_baud` / `reset_chip_uart_to_base_baud` + // step 5: `handle.abort()` before awaiting the JoinHandle. + // Hard pause on BHB56902 now drops the PSU rail instead of + // touching this code path; that path is only hit during + // shutdown, where the process exits and host termios doesn't + // matter. // Drop the per-thread hashrate estimator so a subsequent // re-init starts from zero instead of carrying old samples. self.hashrate_estimator = HashrateEstimator::new(ACTOR_HASHRATE_WINDOW); @@ -1318,13 +1335,18 @@ impl BM13xxActor { }; if let Some((new_rx, new_tx)) = new_streams { - // Swap the actor's chip channels onto the fresh fd, same - // dance as `maybe_switch_chip_baud` step 5: drop the old - // writer, kill the old reader task, spawn a new one. + // Swap the actor's chip channels onto the fresh fd. The + // OLD reader task must be aborted: see comment in + // `maybe_switch_chip_baud` step 5 — the reader task has + // no natural exit signal and its Arc keeps + // the old fd alive, so without an abort the old reader + // races the new reader for kernel TTY bytes and splits + // frames. let stale_reader = self.reader_handle.take(); let _old_tx = std::mem::replace(&mut self.chip_tx, new_tx); drop(_old_tx); if let Some(handle) = stale_reader { + handle.abort(); let _ = tokio::time::timeout(Duration::from_millis(200), handle).await; } self.reader_handle = Some(tokio::spawn(serial_reader_task( diff --git a/mujina-miner/src/board/s19j_pro_amlogic.rs b/mujina-miner/src/board/s19j_pro_amlogic.rs index f229a3fa..5d23973e 100644 --- a/mujina-miner/src/board/s19j_pro_amlogic.rs +++ b/mujina-miner/src/board/s19j_pro_amlogic.rs @@ -352,6 +352,11 @@ impl Board for S19jProAmlogic { Arc::clone(&self.thread_states), Arc::clone(&self.psu), self.config.startup.psu_settle_ms, + self.selected_hashboard.reset_gpio, + self.config.startup.reset_assert_ms, + self.config.startup.initial_voltage, + self.selected_hashboard.eeprom_i2c_device.clone(), + self.selected_hashboard.index, ); let config = self.config.clone(); @@ -374,6 +379,26 @@ struct BoardStateHashThread { thread_states: Arc>>, psu: Arc>, psu_settle_ms: u64, + /// Reset GPIO for the active hashboard. Driven low on resume + /// *before* the PSU comes back up so chips power up while held in + /// reset (matching the cold-boot `assert_all_resets()` step). + reset_gpio: u32, + /// Hold time after asserting reset before bringing the rail + /// down on pause / up on resume. Without this, RST_N is asserted + /// only nanoseconds before the rail transition and the daisy-chained + /// signal does not propagate to all chips in time. + reset_assert_ms: u64, + /// Cold-boot PSU voltage (12 V). The APW12 retains the last + /// commanded voltage across enable/disable, so without this + /// resume comes back at 13.9 V and chips start hot. + initial_voltage_v: f32, + /// i2c device + slot for the PIC handshake. On S19j Pro PIC + /// variants (BHB42601 / BHB42611) the per-domain DC-DCs are + /// gated by an on-board PIC that loses state when the PSU rail + /// drops. Hard resume must re-run the handshake (start_app + + /// enable_dc_dc) before chips have rail to respond on UART. + pic_i2c_device: PathBuf, + pic_slot_index: u8, } impl BoardStateHashThread { @@ -383,6 +408,11 @@ impl BoardStateHashThread { thread_states: Arc>>, psu: Arc>, psu_settle_ms: u64, + reset_gpio: u32, + reset_assert_ms: u64, + initial_voltage_v: f32, + pic_i2c_device: PathBuf, + pic_slot_index: u8, ) -> Self { Self { inner, @@ -390,6 +420,11 @@ impl BoardStateHashThread { thread_states, psu, psu_settle_ms, + reset_gpio, + reset_assert_ms, + initial_voltage_v, + pic_i2c_device, + pic_slot_index, } } @@ -485,27 +520,80 @@ impl HashThread for BoardStateHashThread { if paused { let result = self.inner.set_paused(true).await; self.sync_thread_state(Some(false)); + // Drive RST_N LOW before dropping the rail so chips + // enter synchronous reset before power-off — avoids the + // mid-mining power-yank that left internal flip-flops in + // indeterminate states and broke re-enumeration. + if let Err(e) = SysfsGpio::new(self.reset_gpio).set_output_low() { + warn!(error = %e, reset_gpio = self.reset_gpio, "Failed to assert reset before PSU drop; chain may not pause cleanly"); + } + tokio::time::sleep(Duration::from_millis(self.reset_assert_ms)).await; if let Err(e) = self.psu.lock().await.set_enabled(false) { warn!(error = %e, "set_paused(true) succeeded on chain but PSU disable failed; chips still powered"); } else { info!( thread = %self.inner.name(), - "Hard pause: PSU output disabled — chips drained, board will cool" + "Hard pause: reset asserted then PSU output disabled — chips drained, board will cool" ); } result } else { - if let Err(e) = self.psu.lock().await.set_enabled(true) { - warn!(error = %e, "PSU re-enable failed on resume; chain will not respond"); - return Err(HashThreadError::InitializationFailed(format!( - "PSU re-enable failed: {e}" - ))); + // Hard resume — mirror the cold-boot order in + // `S19jProAmlogic::initialize`: + // 1. Assert hashboard reset BEFORE PSU comes on so chips + // power up while held in reset. + // 2. Drop PSU voltage back to the cold-boot setpoint + // (12 V); the APW12 retains the last value across + // enable cycles and chips need to start at the low rail. + // 3. Enable PSU + settle. + // 4. Re-run the PIC handshake. On PIC-variant S19j Pro + // hashboards (BHB42601 / BHB42611) the on-board PIC + // loses state when the rail drops; without re-enabling + // the per-domain DC-DCs the chips have no rail to + // respond on UART. noPIC variants tolerate the + // handshake failing. + // 5. Resume the inner thread; its next UpdateTask runs + // `initialize_chips()` which releases reset and + // enumerates. + if let Err(e) = SysfsGpio::new(self.reset_gpio).set_output_low() { + warn!(error = %e, reset_gpio = self.reset_gpio, "Failed to assert reset on resume; chain may enumerate poorly"); + } + // Hold reset asserted before PSU comes up so RST_N + // propagates to all chips in the daisy-chain. + tokio::time::sleep(Duration::from_millis(self.reset_assert_ms)).await; + { + let mut psu = self.psu.lock().await; + if let Err(e) = psu.set_voltage(self.initial_voltage_v).await { + warn!(error = %e, "Failed to reset PSU voltage to initial setpoint on resume"); + } + if let Err(e) = psu.set_enabled(true) { + warn!(error = %e, "PSU re-enable failed on resume; chain will not respond"); + return Err(HashThreadError::InitializationFailed(format!( + "PSU re-enable failed: {e}" + ))); + } } tokio::time::sleep(Duration::from_millis(self.psu_settle_ms)).await; + // PIC handshake — best-effort, same as cold boot. + let pic_addr = pic_address_for_slot(self.pic_slot_index); + match PicChain::open(&self.pic_i2c_device, pic_addr) { + Ok(mut pic) => { + if let Err(e) = pic.handshake() { + warn!(addr = format_args!("0x{:02x}", pic_addr), error = %e, "PIC handshake failed on resume; chips may not power up"); + } else if let Err(e) = pic.enable_dc_dc() { + warn!(addr = format_args!("0x{:02x}", pic_addr), error = %e, "PIC enable_dc_dc failed on resume"); + } + } + Err(e) => { + debug!(addr = format_args!("0x{:02x}", pic_addr), error = %e, "PIC absent on resume (noPIC variant) — continuing"); + } + } info!( thread = %self.inner.name(), settle_ms = self.psu_settle_ms, - "Hard resume: PSU back on; next UpdateTask will trigger cold init" + initial_voltage_v = self.initial_voltage_v, + reset_gpio = self.reset_gpio, + "Hard resume: reset asserted, PSU back on at initial voltage, PIC handshake attempted; next UpdateTask will release reset and enumerate" ); self.inner.set_paused(false).await } diff --git a/mujina-miner/src/board/s19k_pro_amlogic.rs b/mujina-miner/src/board/s19k_pro_amlogic.rs index 2c2410dc..b51ec2a2 100644 --- a/mujina-miner/src/board/s19k_pro_amlogic.rs +++ b/mujina-miner/src/board/s19k_pro_amlogic.rs @@ -459,6 +459,11 @@ impl Board for S19kProAmlogic { Arc::clone(&self.thread_states), Arc::clone(&self.psu), self.config.startup.psu_settle_ms, + self.selected_hashboard.reset_gpio, + self.config.startup.reset_assert_ms, + self.config.startup.initial_voltage, + self.selected_hashboard.eeprom_i2c_device.clone(), + self.selected_hashboard.index, ); let config = self.config.clone(); @@ -488,6 +493,32 @@ struct BoardStateHashThread { /// resume so the APW12 has time to stabilize its output before the /// next UpdateTask hits and triggers the cold init / freq ramp. psu_settle_ms: u64, + /// Reset GPIO for the active hashboard. Driven low on resume + /// *before* the PSU comes back up so chips power up while held in + /// reset (matching the cold-boot `assert_all_resets()` step in + /// `initialize()`), and released only when the actor's + /// `initialize_chips()` runs. + reset_gpio: u32, + /// Hold time after asserting reset and before bringing the PSU + /// back up. Without this, RST_N is asserted only nanoseconds + /// before the rail comes alive and the daisy-chained signal does + /// not propagate to all 77 chips in time, leaving a random ~30/77 + /// in indeterminate state. Mirrors the `reset_assert_ms` sleep + /// inside cold-boot `assert_all_resets()`. + reset_assert_ms: u64, + /// Voltage to command on resume *before* PSU enable, so chips + /// power up at the same level as a cold boot (12 V) rather than + /// the last operating voltage (13.9 V) which the APW12 retains + /// across enable cycles. The frequency ramp brings it back up. + initial_voltage_v: f32, + /// i2c device path and PIC address used to re-handshake the on- + /// hashboard PIC16F1704 on resume. The PIC's LDO is fed from the + /// same 12 V rail we drop on pause, so it loses state and the + /// per-domain DC-DCs come back disabled. Without re-running + /// handshake → enable_dc_dc, only chips on domains that happen to + /// power up on their own respond on UART (scattered ~30/77). + pic_i2c_device: PathBuf, + pic_slot_index: u8, } impl BoardStateHashThread { @@ -497,6 +528,11 @@ impl BoardStateHashThread { thread_states: Arc>>, psu: Arc>, psu_settle_ms: u64, + reset_gpio: u32, + reset_assert_ms: u64, + initial_voltage_v: f32, + pic_i2c_device: PathBuf, + pic_slot_index: u8, ) -> Self { Self { inner, @@ -504,6 +540,11 @@ impl BoardStateHashThread { thread_states, psu, psu_settle_ms, + reset_gpio, + reset_assert_ms, + initial_voltage_v, + pic_i2c_device, + pic_slot_index, } } @@ -608,27 +649,124 @@ impl HashThread for BoardStateHashThread { if paused { let result = self.inner.set_paused(true).await; self.sync_thread_state(Some(false)); + // Drive RST_N LOW BEFORE dropping the rail. Chips were + // running with reset released; if we kill power while + // they're still in a free-running state, internal logic + // can land in indeterminate flip-flop states that survive + // a brief power dip — exactly the failure mode the legacy + // disable_chips() path hit (~37/77 chips back, comment + // at thread_v2.rs:1255-1263). Driving reset low first puts + // chips into a known synchronous reset state before the + // rail falls. + if let Err(e) = SysfsGpio::new(self.reset_gpio).set_output_low() { + warn!(error = %e, reset_gpio = self.reset_gpio, "Failed to assert reset before PSU drop; chain may not pause cleanly"); + } + // Give the reset edge time to be sampled by every chip in + // the daisy-chain before the rail collapses. + tokio::time::sleep(Duration::from_millis(self.reset_assert_ms)).await; if let Err(e) = self.psu.lock().await.set_enabled(false) { warn!(error = %e, "set_paused(true) succeeded on chain but PSU disable failed; chips still powered"); } else { info!( thread = %self.inner.name(), - "Hard pause: PSU output disabled — chips drained, board will cool" + "Hard pause: reset asserted then PSU output disabled — chips drained, board will cool" ); } result } else { - if let Err(e) = self.psu.lock().await.set_enabled(true) { - warn!(error = %e, "PSU re-enable failed on resume; chain will not respond"); - return Err(HashThreadError::InitializationFailed(format!( - "PSU re-enable failed: {e}" - ))); + // Hard resume — mirror the cold-boot sequence in + // `S19kProAmlogic::initialize`: + // + // 1. Assert hashboard reset BEFORE the PSU comes on. + // Chips power up while held in reset. The previous + // version didn't do this; chips powered up with reset + // already released and ended up in indeterminate state. + // Verify_chain then saw a random ~30/77 each pass. + // 2. Drop the PSU voltage back to the cold-boot setpoint + // (12 V). The APW12 retains the last commanded + // voltage across an enable/disable cycle, so without + // this re-enable comes back at 13.9 V — too hot a + // start for cold chips, plus the frequency ramp + // expects to begin at the low rail anyway. + // 3. Enable the PSU. Chips now have power, are at 12 V, + // and are held in reset by step 1. + // 4. Sleep psu_settle_ms so the APW12 stabilizes. + // 5. Resume the inner thread. Its next UpdateTask + // triggers `initialize_chips()`, which calls + // `asic_enable.enable()` to release reset and then + // runs enumeration — exactly the cold-boot path. + if let Err(e) = SysfsGpio::new(self.reset_gpio).set_output_low() { + warn!(error = %e, reset_gpio = self.reset_gpio, "Failed to assert reset on resume; chain may enumerate poorly"); + } + // Hold reset asserted before PSU comes up. Without this + // the RST_N edge happens microseconds before the rail + // appears and does not propagate to all 77 daisy-chained + // chips, leaving a random ~30/77 in indeterminate state. + tokio::time::sleep(Duration::from_millis(self.reset_assert_ms)).await; + { + let mut psu = self.psu.lock().await; + if let Err(e) = psu.set_voltage(self.initial_voltage_v).await { + warn!(error = %e, "Failed to reset PSU voltage to initial setpoint on resume"); + } + if let Err(e) = psu.set_enabled(true) { + warn!(error = %e, "PSU re-enable failed on resume; chain will not respond"); + return Err(HashThreadError::InitializationFailed(format!( + "PSU re-enable failed: {e}" + ))); + } } tokio::time::sleep(Duration::from_millis(self.psu_settle_ms)).await; + + // PIC handshake — BHB56902 has a PIC16F1704 that gates the + // per-domain DC-DCs and is fed from the same 12 V rail we + // just dropped. Without re-running handshake → + // enable_dc_dc, only chips on the handful of domains that + // happen to come up power-on survive (verified: scattered + // ~30/77). Mirror the cold-boot path in `initialize`. + // + // Best-effort: any failure here gets a WARN like cold boot; + // the next initialize_chips() will still try to enumerate + // (and will likely fail visibly, which is the right signal). + let pic_addr = pic_address_for_slot(self.pic_slot_index); + match PicChain::open(&self.pic_i2c_device, pic_addr) { + Ok(mut pic) => match pic.handshake() { + Ok(version) => { + info!( + addr = format_args!("0x{:02x}", pic_addr), + version = format_args!("0x{:02x}", version), + "PIC handshake ok on resume" + ); + if let Err(e) = pic.enable_dc_dc() { + warn!( + addr = format_args!("0x{:02x}", pic_addr), + error = %e, + "PIC enable_dc_dc failed on resume; chips may not power up" + ); + } + } + Err(e) => { + warn!( + addr = format_args!("0x{:02x}", pic_addr), + error = %e, + "PIC handshake failed on resume; chain may not respond on UART" + ); + } + }, + Err(e) => { + debug!( + addr = format_args!("0x{:02x}", pic_addr), + error = %e, + "PIC absent on resume (noPIC variant?)" + ); + } + } + info!( thread = %self.inner.name(), settle_ms = self.psu_settle_ms, - "Hard resume: PSU back on; next UpdateTask will trigger cold init" + initial_voltage_v = self.initial_voltage_v, + reset_gpio = self.reset_gpio, + "Hard resume: reset asserted, PSU back on at initial voltage, PIC re-handshaked; next UpdateTask will release reset and enumerate" ); self.inner.set_paused(false).await } From 9f4d22d2bb04ddc0673cc87ccc94c92c80edd9ce Mon Sep 17 00:00:00 2001 From: Michael Schmid Date: Tue, 23 Jun 2026 21:50:31 -0400 Subject: [PATCH 09/10] S19k Pro Amlogic: bring up all present hashboards in parallel `S19kProAmlogic` used to pick the first present hashboard out of the three configured slots and ignore the rest. On a fully-populated control board that's 1/3 of the available hashrate. This commit brings up every present slot in parallel on the shared APW12 / Amlogic SoC, with three coordinated concerns: 1. Detection. `select_present_hashboards` walks every configured slot and returns every one whose detect GPIO reads "present". Order is preserved so per-thread names (HB0/HB1/HB2) match config order. A missing required slot is still fatal; a missing optional slot is still skipped. 2. Lockstep voltage ramp. All chains share one APW12. If each chain runs its own coordinated voltage-frequency ramp independently, the rail will oscillate between the chains' step values -- chain N commanding `set_voltage(12V)` for its step-0 ramp would brown out chain 0 mid-mining at 13.9V. New `ChainCoordinator` in chain_config.rs wraps a `tokio::sync::Barrier`: every chain calls `sync_voltage_step` at the top of every ramp step, the elected leader drives the shared regulator on behalf of the cohort, and all chains then wait the same VOLTAGE_SETTLE_DELAY before sending their per-chain PLL command. All chains compute the same target voltage from the same step index, so it's safe for only the leader to actually touch the rail. `ramp_coordinator` is `Option>` on `ChainPeripherals` and only set when there's more than one chain; single-chain boards (EmberOne, BitAxe, S19j Pro Bitcrane, and S19k/S19j Pro running on a single hashboard) keep the legacy "each actor drives its own regulator" path. 3. Concurrent dispatch. The scheduler dispatches the first UpdateTask to each thread sequentially with `.await`. Without the ramp barrier this was fine -- chain 0 fully ramped before chain 1 ever started -- but with the barrier it deadlocks: chain 0 sits at step 0 waiting for chains 1 and 2 to arrive while they're still waiting for their first UpdateTask to clear chain 0's blocking init. Fix at the actor level: `handle_command` ACKs UpdateTask / ReplaceTask *before* running `handle_work_assignment`, so the scheduler unblocks and dispatches to the next thread while the current actor's initialize_chips runs in the background. Cold init can take ~2 minutes (enumeration + register config + 84- step frequency ramp); without this, chain 0 holds chains 1 and 2 out of init for that whole window. The scheduler in `process_template_event` already discards the `Some(old)` it gets back, so returning `Ok(self.current_task.clone())` instead of waiting for the real result is safe. `S19kProAmlogic::initialize` now health-gates and PIC-handshakes every selected slot before the PSU comes up, then `create_hash_threads` spawns one `BoardStateHashThread` per board sharing the APW12 + `ChainCoordinator`. The pause/resume path on each thread is idempotent on the shared PSU -- the first thread to pause drops the rail, subsequent threads' `set_enabled(false)` is a no-op, and similarly on resume. Telemetry consolidated. `native_telemetry_task` now takes a `Vec` and walks every present hashboard each tick. Spawning N independent tasks would have each one call `state_tx.send_modify` with only its own hashboard's temperatures, clobbering the others. The overtemp gate sums across every sensor on every board so any one hot board still kills the shared rail. Verified on .222 (BHB56902 x3, all 77-chip BM1366 chains): * cold boot: all 3 chains enumerate 77/77, all 3 ramp to 575 MHz, 113 TH/s aggregate sustained (45 + 35 + 40 per chain). * hard pause + resume: rail drops to 0 V across all 3 chains in one PATCH, rail comes back at the cold-boot setpoint, all 6 ramps (3 cold + 3 resume) complete cleanly with 0 chain verification failures, hashrate returns to 114 TH/s. Co-Authored-By: Claude Opus 4.7 --- mujina-miner/src/asic/bm13xx/chain_config.rs | 64 ++ mujina-miner/src/asic/bm13xx/thread_v2.rs | 64 +- mujina-miner/src/board/emberone.rs | 1 + mujina-miner/src/board/s19j_pro_amlogic.rs | 1 + mujina-miner/src/board/s19j_pro_bitcrane.rs | 1 + mujina-miner/src/board/s19k_pro_amlogic.rs | 613 ++++++++++--------- 6 files changed, 445 insertions(+), 299 deletions(-) diff --git a/mujina-miner/src/asic/bm13xx/chain_config.rs b/mujina-miner/src/asic/bm13xx/chain_config.rs index 98af3c3d..478a6432 100644 --- a/mujina-miner/src/asic/bm13xx/chain_config.rs +++ b/mujina-miner/src/asic/bm13xx/chain_config.rs @@ -95,6 +95,69 @@ pub struct ChainPeripherals { /// switch to a higher rate after sending the chip-side /// [`UartBaud`](super::protocol::Register::UartBaud) broadcast. pub chip_uart_baud: Option>>, + + /// Optional cross-chain ramp coordinator. When multiple chains + /// share a single voltage rail (e.g. all three hashboards on an + /// S19k Pro share the APW12), independent per-actor voltage + /// commands would race on the shared mutex and cause the rail to + /// oscillate between chains' step values. Setting this on every + /// chain in the cohort makes them rendezvous at each ramp step + /// and elect one leader per step to drive the shared PSU. + /// `None` keeps the legacy single-chain behaviour where each + /// actor commands its own voltage every step. + pub ramp_coordinator: Option>, +} + +/// Cross-chain coordinator for shared-rail boards. +/// +/// All chains in the cohort call [`ChainCoordinator::sync_voltage_step`] +/// at the top of every frequency-ramp step. The call rendezvous on a +/// barrier (so the slowest chain holds the rest), the elected leader +/// drives the shared voltage regulator on behalf of everyone, and all +/// chains then wait the same `voltage_settle` before returning. After +/// the call returns each chain sends its per-chain PLL command at the +/// same effective voltage. +pub struct ChainCoordinator { + barrier: tokio::sync::Barrier, +} + +impl ChainCoordinator { + /// Create a coordinator for a cohort of `chain_count` chains. + pub fn new(chain_count: usize) -> Self { + Self { + barrier: tokio::sync::Barrier::new(chain_count), + } + } + + /// Rendezvous all chains at this step, command the shared voltage + /// from the elected leader, then settle. + /// + /// `voltage_v`/`voltage_settle` are computed identically by every + /// chain (same step index, same chip target, same domain count), + /// so it's safe for only the leader to actually drive the rail. + /// Returns the wait result so callers can short-circuit logging + /// if only the leader should log. + pub async fn sync_voltage_step( + &self, + regulator: &Arc>, + voltage_v: f32, + voltage_settle: std::time::Duration, + ) -> Result { + let result = self.barrier.wait().await; + let is_leader = result.is_leader(); + if is_leader { + regulator + .lock() + .await + .set_voltage(voltage_v) + .await + .map_err(|e| { + anyhow::anyhow!("ramp-coord leader set_voltage({voltage_v}V): {e}") + })?; + } + tokio::time::sleep(voltage_settle).await; + Ok(is_leader) + } } /// Boxed type for the chip → controller response stream. @@ -193,6 +256,7 @@ mod tests { asic_enable: enable, voltage_regulator: None, chip_uart_baud: None, + ramp_coordinator: None, }; // Hash thread enables diff --git a/mujina-miner/src/asic/bm13xx/thread_v2.rs b/mujina-miner/src/asic/bm13xx/thread_v2.rs index 870ead2b..3d83dc6e 100644 --- a/mujina-miner/src/asic/bm13xx/thread_v2.rs +++ b/mujina-miner/src/asic/bm13xx/thread_v2.rs @@ -565,8 +565,27 @@ impl BM13xxActor { new_task, response_tx, } => { - let result = self.handle_work_assignment(new_task).await; - let _ = response_tx.send(result); + // ACK before running the (potentially slow) work + // assignment. Cold init + frequency ramp can take ~2 + // minutes; the scheduler dispatches UpdateTask to each + // thread sequentially with `.await`, so without this + // early ACK chain 0's init blocks chains 1/2 from ever + // starting theirs — which deadlocks the lockstep ramp + // coordinator when multiple chains share an APW12. + // + // The Ok payload is the previous task (used for + // bookkeeping). On a chain that's mid-init there isn't + // a meaningful "previous" to return; the scheduler in + // `process_template_event` already discards the + // `Some(old)` value, so returning `Ok(None)` here is + // safe. Errors from `handle_work_assignment` are still + // logged below so cold-init failures don't get + // silently swallowed. + let old = self.current_task.clone(); + let _ = response_tx.send(Ok(old)); + if let Err(e) = self.handle_work_assignment(new_task).await { + error!(error = %e, "Work assignment failed"); + } } ThreadCommand::GoIdle { response_tx } => { let result = self.handle_go_idle().await; @@ -1082,24 +1101,33 @@ impl BM13xxActor { } if let Some(ref regulator) = self.peripherals.voltage_regulator { - regulator - .lock() - .await - .set_voltage( - applied_voltage.expect("applied voltage is present when regulator exists"), - ) - .await - .map_err(|e| { + let v = applied_voltage + .expect("applied voltage is present when regulator exists"); + if let Some(coord) = self.peripherals.ramp_coordinator.as_ref() { + // Shared-rail board (e.g. 3 hashboards on one APW12). + // Barrier on every step + leader-elected voltage write + // so the rail doesn't oscillate between chains' + // independently-commanded step values. All chains + // compute the same `v` from `step_index` so it's safe + // for only the leader to drive the regulator. + coord + .sync_voltage_step(regulator, v, VOLTAGE_SETTLE_DELAY) + .await + .map_err(|e| { + HashThreadError::InitializationFailed(format!( + "Ramp coordinator failed at step {step_index} (v={v:.2}V): {e}" + )) + })?; + } else { + // Single-chain path: each actor drives its own + // dedicated regulator independently. + regulator.lock().await.set_voltage(v).await.map_err(|e| { HashThreadError::InitializationFailed(format!( - "Failed to set voltage to {:.2}V: {}", - applied_voltage - .expect("applied voltage is present when regulator exists"), - e + "Failed to set voltage to {v:.2}V: {e}" )) })?; - - // Brief settle time for voltage regulator - time::sleep(VOLTAGE_SETTLE_DELAY).await; + time::sleep(VOLTAGE_SETTLE_DELAY).await; + } } // 2. Set frequency @@ -1694,6 +1722,7 @@ mod tests { asic_enable: Arc::new(Mutex::new(asic_enable)), voltage_regulator: None, chip_uart_baud: None, + ramp_coordinator: None, }, post_broadcast_chip_baud: None, } @@ -1737,6 +1766,7 @@ mod tests { asic_enable: Arc::new(Mutex::new(asic_enable)), voltage_regulator: None, chip_uart_baud: None, + ramp_coordinator: None, }, post_broadcast_chip_baud: None, chain, diff --git a/mujina-miner/src/board/emberone.rs b/mujina-miner/src/board/emberone.rs index b3cce422..92bfb282 100644 --- a/mujina-miner/src/board/emberone.rs +++ b/mujina-miner/src/board/emberone.rs @@ -384,6 +384,7 @@ impl Board for EmberOne { })), voltage_regulator, chip_uart_baud: None, + ramp_coordinator: None, }, post_broadcast_chip_baud: None, }; diff --git a/mujina-miner/src/board/s19j_pro_amlogic.rs b/mujina-miner/src/board/s19j_pro_amlogic.rs index 5d23973e..5154c453 100644 --- a/mujina-miner/src/board/s19j_pro_amlogic.rs +++ b/mujina-miner/src/board/s19j_pro_amlogic.rs @@ -307,6 +307,7 @@ impl Board for S19jProAmlogic { Arc::clone(&self.psu) as Arc> ), chip_uart_baud: None, + ramp_coordinator: None, }, post_broadcast_chip_baud: None, }; diff --git a/mujina-miner/src/board/s19j_pro_bitcrane.rs b/mujina-miner/src/board/s19j_pro_bitcrane.rs index a5e68c86..35a7c914 100644 --- a/mujina-miner/src/board/s19j_pro_bitcrane.rs +++ b/mujina-miner/src/board/s19j_pro_bitcrane.rs @@ -269,6 +269,7 @@ impl Board for S19jProBitcrane { asic_enable: Arc::new(Mutex::new(S19jProBitcraneAsicEnable { reset_pin })), voltage_regulator, chip_uart_baud: None, + ramp_coordinator: None, }, post_broadcast_chip_baud: None, }; diff --git a/mujina-miner/src/board/s19k_pro_amlogic.rs b/mujina-miner/src/board/s19k_pro_amlogic.rs index b51ec2a2..d4697c5e 100644 --- a/mujina-miner/src/board/s19k_pro_amlogic.rs +++ b/mujina-miner/src/board/s19k_pro_amlogic.rs @@ -145,11 +145,26 @@ pub fn device_id(config: &AmlogicControlBoardConfig) -> String { .unwrap_or_else(|| DEFAULT_BOARD_NAME.to_string()) } +/// One hashboard, after `select_present_hashboards` + `perform_health_gate`. +#[derive(Clone)] +struct SelectedHashboard { + config: AmlogicHashboardConfig, + board_serial: Option, +} + /// Native Amlogic S19k Pro board. +/// +/// Holds one or more `SelectedHashboard`s — the BHB56902 hardware +/// supports up to three hashboards on the same APW12 / Amlogic SoC, +/// and `select_present_hashboards` picks every slot whose detect GPIO +/// reads "present". `create_hash_threads` then spawns one +/// [`BoardStateHashThread`] per board so each chain mines +/// independently; the shared APW12 is coordinated through a +/// [`bm13xx::chain_config::ChainCoordinator`] so the per-step voltage +/// commands during the frequency ramp don't oscillate across chains. pub struct S19kProAmlogic { config: AmlogicControlBoardConfig, - selected_hashboard: AmlogicHashboardConfig, - board_serial: Option, + selected_hashboards: Vec, psu: Arc>, state_tx: watch::Sender, thread_states: Arc>>, @@ -159,15 +174,13 @@ pub struct S19kProAmlogic { impl S19kProAmlogic { fn new( config: AmlogicControlBoardConfig, - selected_hashboard: AmlogicHashboardConfig, - board_serial: Option, + selected_hashboards: Vec, psu: Arc>, state_tx: watch::Sender, ) -> Self { Self { config, - selected_hashboard, - board_serial, + selected_hashboards, psu, state_tx, thread_states: Arc::new(std::sync::Mutex::new(Vec::new())), @@ -178,26 +191,34 @@ impl S19kProAmlogic { async fn initialize( config: &AmlogicControlBoardConfig, state_tx: &watch::Sender, - ) -> Result< - ( - AmlogicHashboardConfig, - Option, - Arc>, - ), - BoardError, - > { - let selected_hashboard = select_hashboard(config)?; + ) -> Result<(Vec, Arc>), BoardError> { let board_name = device_id(config); + let present = select_present_hashboards(config)?; info!( board = %board_name, - hashboard = selected_hashboard.index, - serial = %selected_hashboard.serial_path.display(), + slots = %present + .iter() + .map(|hb| hb.index.to_string()) + .collect::>() + .join(","), "Initializing native Amlogic S19k Pro board" ); - let (board_serial, initial_temperatures) = - perform_health_gate(config, &selected_hashboard)?; + // Health-gate every present hashboard before energizing chips. + // EEPROM + temperature reads can fail individually; a failure + // here aborts board init for now (we don't want a "ghost" + // hashboard with bad EEPROM coming up with the others). + let mut selected: Vec = Vec::with_capacity(present.len()); + let mut all_temps: Vec = Vec::new(); + for hb in &present { + let (board_serial, initial_temperatures) = perform_health_gate(config, hb)?; + all_temps.extend(initial_temperatures); + selected.push(SelectedHashboard { + config: hb.clone(), + board_serial, + }); + } configure_fans(config, config.startup.default_fan_percent)?; assert_all_resets(config)?; @@ -225,65 +246,74 @@ impl S19kProAmlogic { psu_guard.measure_voltage().ok() }; - // PIC handshake. The BHB56902 (S19k Pro), like its BHB42601 / - // BHB42611 (S19j Pro) siblings, gates the per-domain DC-DC - // regulators behind an on-hashboard PIC16F1704 microcontroller. - // The BM1366 chips have no power to respond on UART until we - // enable them via the PIC. + // PIC handshake — best-effort per present hashboard. On + // PIC-variant boards (BHB42601 / BHB42611) the per-domain + // DC-DC regulators are gated by an on-hashboard PIC16F1704 + // microcontroller and have to be unlocked here. On noPIC + // variants (BHB56902 / S19k Pro family) the open or + // handshake fails and we continue — the chain still comes up + // via the existing path. + // // See "PIC vs noPIC Bitmain Miners": // https://braiins.com/blog/pic-vs-nopic-bitmain-miners-... - // - // The protocol opcodes used by `PicChain` were lifted from the - // decompiled S21 single_board_test in + // The protocol opcodes used by `PicChain` were lifted from + // the decompiled S21 single_board_test in // https://github.com/HashSource/bitmain_antminer_binaries - // and confirmed against LuxOS ftrace captures on the BHB42601. - // The BHB56902 uses the same PIC firmware family (version 0x89 - // observed on both) and reuses this sequence: - // reset -> start_app -> get_sw_ver -> disable_dc_dc -> enable_dc_dc - // - // The PIC's onboard LDO is fed from the 12 V rail, so handshake - // must run AFTER `set_enabled(true)` above. Failures are - // tolerated so any future noPIC variant of the S19k Pro can - // still bring up via the existing path. - let pic_addr = pic_address_for_slot(selected_hashboard.index); - match PicChain::open(&selected_hashboard.eeprom_i2c_device, pic_addr) { - Ok(mut pic) => match pic.handshake() { - Ok(version) => { - info!( - addr = format_args!("0x{:02x}", pic_addr), - version = format_args!("0x{:02x}", version), - "PIC handshake ok" - ); - if let Err(e) = pic.enable_dc_dc() { - warn!( + // and confirmed against LuxOS ftrace captures on BHB42601. + for sel in &selected { + let pic_addr = pic_address_for_slot(sel.config.index); + match PicChain::open(&sel.config.eeprom_i2c_device, pic_addr) { + Ok(mut pic) => match pic.handshake() { + Ok(version) => { + info!( + hashboard = sel.config.index, addr = format_args!("0x{:02x}", pic_addr), - error = %e, - "PIC enable_dc_dc failed; chips may not power up" + version = format_args!("0x{:02x}", version), + "PIC handshake ok" ); - } else { - info!( + if let Err(e) = pic.enable_dc_dc() { + warn!( + hashboard = sel.config.index, + addr = format_args!("0x{:02x}", pic_addr), + error = %e, + "PIC enable_dc_dc failed; chips may not power up" + ); + } else { + info!( + hashboard = sel.config.index, + addr = format_args!("0x{:02x}", pic_addr), + "PIC DC-DC enabled; chips powering up" + ); + } + } + Err(e) => { + debug!( + hashboard = sel.config.index, addr = format_args!("0x{:02x}", pic_addr), - "PIC DC-DC enabled; chips powering up" + error = %e, + "PIC handshake failed (noPIC variant?); continuing" ); } - } + }, Err(e) => { - warn!( + debug!( + hashboard = sel.config.index, addr = format_args!("0x{:02x}", pic_addr), error = %e, - "PIC handshake failed; chain may not respond on UART" + "PIC i2c open failed (noPIC variant?); continuing" ); } - }, - Err(e) => { - warn!( - addr = format_args!("0x{:02x}", pic_addr), - error = %e, - "Could not open PIC i2c device; non-PIC hashboard variant?" - ); } } + // First board's serial gets reported as the board serial. + // Could expose all serials in the future, but the API model + // currently has one serial per BoardState. + let primary_serial = selected + .iter() + .find_map(|s| s.board_serial.clone()) + .or_else(|| Some(board_name.clone())); + let fan_states = build_fan_state(config, config.startup.default_fan_percent); let power_states = vec![PowerMeasurement { name: "apw12".into(), @@ -295,13 +325,13 @@ impl S19kProAmlogic { state_tx.send_modify(|state| { state.name = board_name.clone(); state.model = BOARD_MODEL.into(); - state.serial = board_serial.clone().or_else(|| Some(board_name.clone())); - state.temperatures = initial_temperatures.clone(); + state.serial = primary_serial.clone(); + state.temperatures = all_temps.clone(); state.fans = fan_states.clone(); state.powers = power_states.clone(); }); - Ok((selected_hashboard, board_serial, psu)) + Ok((selected, psu)) } } @@ -312,8 +342,9 @@ impl Board for S19kProAmlogic { model: BOARD_MODEL.into(), firmware_version: None, serial_number: self - .board_serial - .clone() + .selected_hashboards + .iter() + .find_map(|s| s.board_serial.clone()) .or_else(|| Some(device_id(&self.config))), } } @@ -326,18 +357,20 @@ impl Board for S19kProAmlogic { assert_all_resets(&self.config)?; configure_fans(&self.config, 0)?; - // Best-effort disable of PIC DC-DC before cutting the rail. If this - // fails the PSU output-off below still safes the chips. - let pic_addr = pic_address_for_slot(self.selected_hashboard.index); - if let Ok(mut pic) = - PicChain::open(&self.selected_hashboard.eeprom_i2c_device, pic_addr) - { - if let Err(e) = pic.disable_dc_dc() { - warn!( - addr = format_args!("0x{:02x}", pic_addr), - error = %e, - "PIC disable_dc_dc on shutdown failed (non-fatal)" - ); + // Best-effort disable PIC DC-DC on every present hashboard + // before cutting the rail. PSU output-off below still safes + // the chips even if some PIC opens fail (noPIC variants). + for sel in &self.selected_hashboards { + let pic_addr = pic_address_for_slot(sel.config.index); + if let Ok(mut pic) = PicChain::open(&sel.config.eeprom_i2c_device, pic_addr) { + if let Err(e) = pic.disable_dc_dc() { + debug!( + hashboard = sel.config.index, + addr = format_args!("0x{:02x}", pic_addr), + error = %e, + "PIC disable_dc_dc on shutdown failed (non-fatal)" + ); + } } } @@ -351,132 +384,148 @@ impl Board for S19kProAmlogic { } async fn create_hash_threads(&mut self) -> Result>, BoardError> { - let data_stream = SerialStream::new( - &self.selected_hashboard.serial_path.to_string_lossy(), - SERIAL_BAUD, - ) - .map_err(|e| BoardError::InitializationFailed(format!("Failed to open data port: {e}")))?; - let (data_reader, data_writer, data_control) = data_stream.split(); - - data_control.flush_input().map_err(|e| { - BoardError::InitializationFailed(format!("Failed to flush serial buffer: {e}")) - })?; - - let chip_rx = FramedRead::new(data_reader, bm13xx::FrameCodec); - let chip_tx = FramedWrite::new(data_writer, bm13xx::FrameCodec); - - // Hand the hash thread a handle for the chip-channel SerialStream - // so it can close+reopen `/dev/ttyS2` at a new baud rate mid-init. - // The original `data_control` is stashed in - // `_original_keepalive` so its Arc reference keeps - // the first fd open for the lifetime of the adapter (and the - // mining session). LuxOS keeps its first fd open the entire - // time it runs — without that we get chip drops on the swap. - let chip_uart_baud = Arc::new(Mutex::new(SerialControlAdapter { - path: self.selected_hashboard.serial_path.clone(), - staged_control: None, - _original_keepalive: Some(data_control), - })); - - let config = ChainConfig { - name: format!("S19kProAmlogic-HB{}", self.selected_hashboard.index), - topology: TopologySpec::uniform_domains(11, 7, false), - chip_config: chip_config::bm1366(), - peripherals: ChainPeripherals { - asic_enable: Arc::new(Mutex::new(NativeResetControl { - gpio: SysfsGpio::new(self.selected_hashboard.reset_gpio), - reset_release_ms: self.config.startup.reset_release_ms, - })), - voltage_regulator: Some( - Arc::clone(&self.psu) as Arc> - ), - chip_uart_baud: Some(chip_uart_baud - as Arc>), - }, - // Baud bump to 3.125 Mbaud. The post-broadcast switch is - // critical to break past the UART RX cap at 115200: with - // 77 chips producing ~32 TH/s of nonces, the 1000-frames/s - // ceiling at 115200 drops half the share-bearing nonces. - // LuxOS sustains 38 TH/s on this same hashboard at this - // baud (per `captures/luxos-bhb56902-steady-state.log`). - // - // Two prior mismatches were dropping chips on the switch: - // 1. mujina mapped `Baud3M` to 3_000_000 numeric. The - // chip-side register `0x00003011` actually produces - // 3_125_000 baud, not 3_000_000 — the ~4 % mismatch - // was enough to mangle frames on the long chain. Now - // fixed in `thread_v2.rs`. - // 2. The board dropped the original `SerialControl` after - // handing it to the actor, so when the actor swapped - // to the new fd there was a brief no-fd window before - // the new fd took over. LuxOS keeps its first fd open - // the entire session — now `SerialControlAdapter - // ._original_keepalive` holds the original control - // alive for the whole adapter lifetime. - post_broadcast_chip_baud: Some(bm13xx::protocol::BaudRate::Baud3M), + let n_chains = self.selected_hashboards.len(); + // Single APW12 across all hashboards. Lockstep voltage commands + // through a shared coordinator when there's more than one chain, + // otherwise leave the legacy path where each actor drives its + // own regulator independently. + let ramp_coordinator = if n_chains > 1 { + Some(Arc::new(bm13xx::chain_config::ChainCoordinator::new( + n_chains, + ))) + } else { + None }; - let thread = thread_v2::BM13xxThread::new(chip_rx, chip_tx, config).map_err(|e| { - BoardError::InitializationFailed(format!("Failed to create hash thread: {e}")) - })?; + let mut threads: Vec> = Vec::with_capacity(n_chains); + let mut thread_state_seed: Vec = + Vec::with_capacity(n_chains); + + for selected in self.selected_hashboards.clone() { + let hb = &selected.config; + + let data_stream = SerialStream::new(&hb.serial_path.to_string_lossy(), SERIAL_BAUD) + .map_err(|e| { + BoardError::InitializationFailed(format!( + "Failed to open data port {}: {e}", + hb.serial_path.display() + )) + })?; + let (data_reader, data_writer, data_control) = data_stream.split(); - let thread_name = thread.name().to_string(); - // Seed the initial hashrate at 0; the actor's HashrateEstimator - // takes over once shares start flowing. Reporting the static - // `capabilities.hashrate_estimate` here would show 6.39 TH/s - // on the per-board UI during the ramp while the chain-wide - // hashrate is still 0 — see the matching change in - // `thread_hashrate_value` below. - let thread_hashrate = 0u64; + data_control.flush_input().map_err(|e| { + BoardError::InitializationFailed(format!("Failed to flush serial buffer: {e}")) + })?; + let chip_rx = FramedRead::new(data_reader, bm13xx::FrameCodec); + let chip_tx = FramedWrite::new(data_writer, bm13xx::FrameCodec); + + // Original SerialControl held alive in the adapter so the + // `/dev/ttyS*` fd never closes mid-init when the actor + // swaps writer/reader pairs across a baud bump. Per- + // hashboard adapter so each chain owns its own keepalive. + let chip_uart_baud = Arc::new(Mutex::new(SerialControlAdapter { + path: hb.serial_path.clone(), + staged_control: None, + _original_keepalive: Some(data_control), + })); + + let config = ChainConfig { + name: format!("S19kProAmlogic-HB{}", hb.index), + topology: TopologySpec::uniform_domains(11, 7, false), + chip_config: chip_config::bm1366(), + peripherals: ChainPeripherals { + asic_enable: Arc::new(Mutex::new(NativeResetControl { + gpio: SysfsGpio::new(hb.reset_gpio), + reset_release_ms: self.config.startup.reset_release_ms, + })), + voltage_regulator: Some( + Arc::clone(&self.psu) as Arc> + ), + chip_uart_baud: Some(chip_uart_baud + as Arc>), + ramp_coordinator: ramp_coordinator.clone(), + }, + // 3.125 Mbaud post-broadcast switch — see commit + // history for the chip-side register / numeric-rate + // and fd-keepalive fixes that made this reliable. + post_broadcast_chip_baud: Some(bm13xx::protocol::BaudRate::Baud3M), + }; + + let inner_thread = + thread_v2::BM13xxThread::new(chip_rx, chip_tx, config).map_err(|e| { + BoardError::InitializationFailed(format!( + "Failed to create hash thread for HB{}: {e}", + hb.index + )) + })?; + + let thread_name = inner_thread.name().to_string(); + thread_state_seed.push(crate::api_client::types::ThreadState { + name: thread_name.clone(), + hashrate: 0, + is_active: false, + }); + + let board_thread = BoardStateHashThread::new( + Box::new(inner_thread), + self.state_tx.clone(), + Arc::clone(&self.thread_states), + Arc::clone(&self.psu), + self.config.startup.psu_settle_ms, + hb.reset_gpio, + self.config.startup.reset_assert_ms, + self.config.startup.initial_voltage, + hb.eeprom_i2c_device.clone(), + hb.index, + ); + + threads.push(Box::new(board_thread)); + } + + // Seed both the BoardState.threads field (for the UI) and the + // shared `thread_states` cache (read by the consolidated + // telemetry task) with one slot per chain. Live hashrates + // arrive via `sync_thread_state` as each actor mines. self.state_tx.send_modify(|state| { state.serial = self - .board_serial - .clone() + .selected_hashboards + .iter() + .find_map(|s| s.board_serial.clone()) .or_else(|| Some(device_id(&self.config))); - state.threads = vec![crate::api_client::types::ThreadState { - name: thread_name.clone(), - hashrate: thread_hashrate, - is_active: false, - }]; + state.threads = thread_state_seed.clone(); }); - { let mut thread_states = self .thread_states .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - *thread_states = vec![crate::api_client::types::ThreadState { - name: thread_name.clone(), - hashrate: thread_hashrate, - is_active: false, - }]; + *thread_states = thread_state_seed.clone(); } - let thread = BoardStateHashThread::new( - Box::new(thread), - self.state_tx.clone(), - Arc::clone(&self.thread_states), - Arc::clone(&self.psu), - self.config.startup.psu_settle_ms, - self.selected_hashboard.reset_gpio, - self.config.startup.reset_assert_ms, - self.config.startup.initial_voltage, - self.selected_hashboard.eeprom_i2c_device.clone(), - self.selected_hashboard.index, - ); - - let config = self.config.clone(); - let hashboard = self.selected_hashboard.clone(); + // ONE telemetry task across all selected hashboards. It walks + // every present hashboard each tick, aggregates temperatures / + // fans / threads / PSU voltage into a single BoardState + // update, and acts as the overtemp gate (kills PSU if any + // sensor exceeds the cutoff). Spawning N independent tasks + // would have them race on `state_tx.send_modify` and clobber + // each other's temperatures. + let cfg_clone = self.config.clone(); + let hbs_clone: Vec = self + .selected_hashboards + .iter() + .map(|s| s.config.clone()) + .collect(); let psu = Arc::clone(&self.psu); let state_tx = self.state_tx.clone(); let thread_states = Arc::clone(&self.thread_states); let shutdown = self.telemetry_shutdown.child_token(); tokio::spawn(async move { - native_telemetry_task(config, hashboard, psu, state_tx, thread_states, shutdown).await; + native_telemetry_task(cfg_clone, hbs_clone, psu, state_tx, thread_states, shutdown) + .await; }); - Ok(vec![Box::new(thread)]) + Ok(threads) } } @@ -1000,19 +1049,24 @@ impl VoltageRegulator for NativeAmlogicPsu { } } -fn select_hashboard( +/// Walk every configured hashboard slot; return every one whose detect +/// GPIO reads "present". Missing required slots are fatal, missing +/// optional slots are skipped. The order matches the config order +/// (and therefore the slot index), so per-thread naming stays stable +/// across runs. +fn select_present_hashboards( config: &AmlogicControlBoardConfig, -) -> Result { +) -> Result, BoardError> { if config.hashboards.is_empty() { return Err(BoardError::InitializationFailed( "Amlogic config has no configured hashboards".into(), )); } - let mut first_present = None; + let mut present = Vec::new(); for hashboard in &config.hashboards { - let present = is_hashboard_present(hashboard)?; - if !present { + let is_present = is_hashboard_present(hashboard)?; + if !is_present { let missing_is_fatal = config .startup .health_gate @@ -1026,15 +1080,15 @@ fn select_hashboard( } continue; } - - if first_present.is_none() { - first_present = Some(hashboard.clone()); - } + present.push(hashboard.clone()); } - first_present.ok_or_else(|| { - BoardError::InitializationFailed("No configured hashboards are present".into()) - }) + if present.is_empty() { + return Err(BoardError::InitializationFailed( + "No configured hashboards are present".into(), + )); + } + Ok(present) } fn is_hashboard_present(hashboard: &AmlogicHashboardConfig) -> Result { @@ -1132,7 +1186,7 @@ fn build_fan_state(config: &AmlogicControlBoardConfig, percent: u8) -> Vec async fn native_telemetry_task( config: AmlogicControlBoardConfig, - hashboard: AmlogicHashboardConfig, + hashboards: Vec, psu: Arc>, state_tx: watch::Sender, thread_states: Arc>>, @@ -1140,121 +1194,116 @@ async fn native_telemetry_task( ) { const TELEMETRY_INTERVAL: Duration = Duration::from_secs(2); - // Open a dedicated PIC handle for the heartbeat path. LuxOS sends a - // PIC heartbeat (opcode 0x16) periodically while mining — captured at - // roughly every 1.5 s in our ftrace runs. Without heartbeats the PIC - // appears to disable DC-DC after a watchdog timeout (we observed chips - // dropping mid-ramp without it). The exact timeout isn't documented; - // LuxOS firmware never lets the gap grow large enough to find out. - // - // We piggy-back on the 2 s telemetry tick so heartbeat + temp reads - // share one PIC handle and the same i2c-0 transactions don't race. - // Failing to open just disables the heartbeat (board may still come up - // briefly). - // Probe for the PIC at the expected i2c address. The BHB56902 - // (S19k Pro) is a noPIC variant — no chip ACKs at 0x21 / 0x22. - // Without a probe we'd spam a "PIC heartbeat failed" warning every - // ~2 s for the life of the process. Try one heartbeat; if it - // fails, disable the path entirely. - let pic_addr = pic_address_for_slot(hashboard.index); - let mut pic_for_heartbeat: Option = match PicChain::open( - &hashboard.eeprom_i2c_device, - pic_addr, - ) { - Ok(mut p) => match p.heartbeat() { - Ok(()) => Some(p), + // One PIC handle per present hashboard for the heartbeat path. + // LuxOS sends a PIC heartbeat (opcode 0x16) roughly every 1.5 s on + // PIC variants; without heartbeats the PIC disables DC-DC after a + // watchdog timeout (chips drop mid-ramp). Probe each slot once at + // task start; noPIC variants (BHB56902) silently disable the + // heartbeat for that slot to avoid spamming warnings. + let mut pics: Vec<(u16, AmlogicHashboardConfig, Option)> = + Vec::with_capacity(hashboards.len()); + for hb in &hashboards { + let pic_addr = pic_address_for_slot(hb.index); + let pic = match PicChain::open(&hb.eeprom_i2c_device, pic_addr) { + Ok(mut p) => match p.heartbeat() { + Ok(()) => Some(p), + Err(e) => { + info!( + hashboard = hb.index, + addr = format_args!("0x{:02x}", pic_addr), + error = %e, + "PIC absent on this hashboard (likely a noPIC variant); \ + skipping heartbeat path" + ); + None + } + }, Err(e) => { - info!( + warn!( + hashboard = hb.index, addr = format_args!("0x{:02x}", pic_addr), error = %e, - "PIC absent on this hashboard (likely a noPIC variant like BHB56902); \ - skipping heartbeat path" + "could not open PIC for heartbeat task; chips may drop after watchdog timeout" ); None } - }, - Err(e) => { - warn!( - addr = format_args!("0x{:02x}", pic_addr), - error = %e, - "could not open PIC for heartbeat task; chips may drop after watchdog timeout" - ); - None - } - }; + }; + pics.push((pic_addr, hb.clone(), pic)); + } + + // Stock Bitmain firmware uses ~95 °C hard shutdown / ~85 °C throttle + // on this chip family. 75 °C is conservative for the bench setup; + // configurable threshold is future work. + const OVERTEMP_CUTOFF_C: f32 = 75.0; loop { if shutdown.is_cancelled() { break; } - // Heartbeat + read PIC-mediated temps using a single PIC handle to - // avoid racing with a separately-opened temp reader. + // Walk every present hashboard each tick: PIC heartbeat, + // PIC-mediated temps, TMP75 fallback. Accumulate into one + // sensor list keyed by `HB{index}-...` so the API surfaces + // per-hashboard sensors in the same BoardState. let mut temperatures: Vec = Vec::new(); - if let Some(ref mut pic) = pic_for_heartbeat { - if let Err(e) = pic.heartbeat() { - warn!( - addr = format_args!("0x{:02x}", pic_addr), - error = %e, - "PIC heartbeat failed" - ); - } - match pic.read_temperatures_celsius() { - Ok(temps) => { - for (i, t) in temps.iter().enumerate() { - temperatures.push(TemperatureSensor { - name: format!("HB{}-PIC{}", hashboard.index, i), - temperature_c: Some(*t), - }); - } - } - Err(e) => { - debug!( + for (pic_addr, hb, pic_opt) in pics.iter_mut() { + let pic_addr = *pic_addr; + let mut got_pic_temps = false; + if let Some(pic) = pic_opt.as_mut() { + if let Err(e) = pic.heartbeat() { + warn!( + hashboard = hb.index, addr = format_args!("0x{:02x}", pic_addr), error = %e, - "PIC temperature read failed" + "PIC heartbeat failed" ); } + match pic.read_temperatures_celsius() { + Ok(temps) => { + for (i, t) in temps.iter().enumerate() { + temperatures.push(TemperatureSensor { + name: format!("HB{}-PIC{}", hb.index, i), + temperature_c: Some(*t), + }); + } + got_pic_temps = !temps.is_empty(); + } + Err(e) => { + debug!( + hashboard = hb.index, + addr = format_args!("0x{:02x}", pic_addr), + error = %e, + "PIC temperature read failed" + ); + } + } } - } - // Fallback to TMP75 path when no PIC-mediated temps were returned - // (e.g. noPIC variants). read_temperatures() handles the TMP75 case. - if temperatures.is_empty() { - match read_temperatures(&hashboard) { - Ok(t) => temperatures = t, - Err(error) => { - debug!(board = %hashboard.index, error = %error, "Native telemetry temperature read failed"); + if !got_pic_temps { + match read_temperatures(hb) { + Ok(t) => temperatures.extend(t), + Err(error) => { + debug!(board = hb.index, error = %error, "TMP75 temperature read failed"); + } } } } - // Overtemp protection: if any PIC sensor exceeds the cutoff, - // immediately disable DC-DC (cuts chip power but keeps PIC alive) - // and PSU output, then cancel telemetry so the daemon notices and - // shuts the board down. - // - // Stock Bitmain firmware uses ~95 °C for hard shutdown and ~85 °C - // for throttling on this chip family. We use 75 °C as a - // conservative fixed cutoff: this code path is exercised on the - // bench (limited cooling) much more than in chassis, and the - // failure mode of running a hashboard hot is severe (the original - // BHB56902 we used to bring this up arced its 12 V input plane). - // A configurable threshold is the right long-term answer; left as - // future work to keep this PR focused. - const OVERTEMP_CUTOFF_C: f32 = 75.0; + // Overtemp gate: ANY sensor on ANY hashboard above cutoff + // kills DC-DC on every PIC + cuts the shared PSU output. let hottest = temperatures .iter() .filter_map(|t| t.temperature_c) .fold(0f32, f32::max); if hottest >= OVERTEMP_CUTOFF_C { error!( - board = %hashboard.index, hottest = hottest, cutoff = OVERTEMP_CUTOFF_C, - "OVERTEMP — disabling PIC DC-DC and PSU output" + "OVERTEMP — disabling all PIC DC-DC outputs and PSU output" ); - if let Some(ref mut pic) = pic_for_heartbeat { - let _ = pic.disable_dc_dc(); + for (_, _, pic_opt) in pics.iter_mut() { + if let Some(pic) = pic_opt.as_mut() { + let _ = pic.disable_dc_dc(); + } } let _ = psu.lock().await.set_enabled(false); shutdown.cancel(); @@ -1513,11 +1562,11 @@ async fn create_amlogic_board() }; let (state_tx, state_rx) = watch::channel(initial_state); - let (selected_hashboard, board_serial, psu) = S19kProAmlogic::initialize(&config, &state_tx) + let (selected_hashboards, psu) = S19kProAmlogic::initialize(&config, &state_tx) .await .map_err(|e| Error::Hardware(format!("Failed to initialize native Amlogic board: {e}")))?; - let board = S19kProAmlogic::new(config, selected_hashboard, board_serial, psu, state_tx); + let board = S19kProAmlogic::new(config, selected_hashboards, psu, state_tx); let registration = super::BoardRegistration { state_rx }; Ok((Box::new(board), registration)) } From 538f00b2741811aaa9a33d942f4309fa91f6fb65 Mon Sep 17 00:00:00 2001 From: Michael Schmid Date: Sun, 21 Jun 2026 22:08:15 -0400 Subject: [PATCH 10/10] Fan safety: dynamic curve, sensor watchdog, lower cutoff, 100% default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two boards already destroyed by thermal events under the previous fan control: a single boot-time PWM write at config.startup.default_fan_percent (50%), no in-loop adjustment, an overtemp cutoff at 75 °C TMP75 that never tripped because the TMP75 sensors under-read the BM1366 die by ~15 °C (per Braiins's own metric annotation, where `chip_temperature_celsius` = `pcb_temperature_celsius` + 15 with `source="approximated(pcb_sensor)"`), and a swapped HB0/HB2 sensor address map that silently pointed the cutoff at the wrong slot for any deployment not running on HB1. Changes, both in `native_telemetry_task` (s19k_pro_amlogic and s19j_pro_amlogic): 1. Dynamic fan curve from the hottest TMP75 reading on the board. Floor 60% below 40 °C, linear ramp to 100% at 60 °C. Logged every time the target duty changes (and only then) so the log isn't spammed at steady state. 2. Watchdog: if no TMP75 sample has landed in 30 s (i2c stuck, sensor died, telemetry task stalled and just resumed), pin the fans to 100 % until a fresh read recovers. 3. TMP75 hard cutoff lowered 75 -> 65 °C. Stock Bitmain's pid_target_temp on this board is 60 (per recon/.../etc/topol_BHB56902.conf) with hot at 80 and dangerous at 90 -- but those numbers presuppose chassis airflow we don't have on the bench. 65 °C TMP75 ~= 80 °C estimated die, which matches stock's "hot" boundary while leaving us with the same PSU-drop + telemetry-cancel safety path. 4. HB0/HB2 TMP75 address swap fix. `tmp75_addresses` previously returned [0x4E, 0x4A] for HB0 and [0x48, 0x4C] for HB2; the stock Bitmain mapping (and `amlogic-cb-tools::hashboard_s19jpro`) is HB0=[0x48,0x4C], HB1=[0x4D,0x49], HB2=[0x4E,0x4A]. Also confirmed against `topol_BHB56902.conf`'s `ctrlboardsensor` block and a live `i2c-probe` sweep on .222. Both s19k_pro and s19j_pro versions had the swap. 5. mujina.toml and mujina-hb2.toml: default_fan_percent 50 -> 100 so the boot window (before the telemetry task takes over) is at full airflow instead of the previous half. Validated on .222 (BHB56902, single HB1, noPIC) running through cold init and freq ramp: TMP75 climbed 23 -> 43 °C, fan curve tracked correctly (60% floor through 40 °C, ramping to 66-67% by 43 °C), no cutoff triggered, no watchdog fallback. Co-Authored-By: Claude Opus 4.7 --- mujina-hb2.toml | 2 +- mujina-miner/src/board/s19j_pro_amlogic.rs | 109 ++++++++++++++---- mujina-miner/src/board/s19k_pro_amlogic.rs | 124 ++++++++++++++++++--- mujina.toml | 2 +- 4 files changed, 199 insertions(+), 38 deletions(-) diff --git a/mujina-hb2.toml b/mujina-hb2.toml index 4f4d3182..2dc258b0 100644 --- a/mujina-hb2.toml +++ b/mujina-hb2.toml @@ -9,7 +9,7 @@ write_register = 17 enable_gpio = 437 [hardware.amlogic_control_board.startup] -default_fan_percent = 50 +default_fan_percent = 100 initial_voltage = 12.0 psu_settle_ms = 2000 reset_assert_ms = 100 diff --git a/mujina-miner/src/board/s19j_pro_amlogic.rs b/mujina-miner/src/board/s19j_pro_amlogic.rs index 5154c453..3c09ef2c 100644 --- a/mujina-miner/src/board/s19j_pro_amlogic.rs +++ b/mujina-miner/src/board/s19j_pro_amlogic.rs @@ -987,6 +987,27 @@ async fn native_telemetry_task( } }; + // ----- Fan + overtemp control -------------------------------- + // + // s19j_pro has a PIC, which gives us PIC-mediated thermal reads + // (above) in addition to the TMP75 PCB sensors. We use whichever + // sensor pool we actually got back (PIC first, TMP75 fallback) + // and drive a single dynamic fan curve + overtemp cutoff off the + // hottest value. + // + // See `s19k_pro_amlogic.rs::native_telemetry_task` for the + // longer discussion of why 65 °C ~= 80 °C die ~= stock's "hot" + // boundary, and why we treat 100 % fan as the floor for a + // failed/stale sensor read. + const TMP75_OVERTEMP_C: f32 = 65.0; + const FAN_FLOOR_PERCENT: u8 = 60; + const FAN_RAMP_START_C: f32 = 40.0; + const FAN_RAMP_FULL_C: f32 = 60.0; + const TEMP_STALE_AFTER: Duration = Duration::from_secs(30); + + let mut applied_fan_percent: Option = None; + let mut last_temp_at = std::time::Instant::now(); + loop { if shutdown.is_cancelled() { break; @@ -1032,29 +1053,21 @@ async fn native_telemetry_task( } } - // Overtemp protection: if any PIC sensor exceeds the cutoff, - // immediately disable DC-DC (cuts chip power but keeps PIC alive) - // and PSU output, then cancel telemetry so the daemon notices and - // shuts the board down. - // - // Stock Bitmain firmware uses ~95 °C for hard shutdown and ~85 °C - // for throttling on this chip family. We use 75 °C as a - // conservative fixed cutoff: this code path is exercised on the - // bench (limited cooling) much more than in chassis, and the - // failure mode of running a hashboard hot is severe (the original - // BHB42601 we used to bring this up arced its 12 V input plane). - // A configurable threshold is the right long-term answer; left as - // future work to keep this PR focused. - const OVERTEMP_CUTOFF_C: f32 = 75.0; - let hottest = temperatures + let board_hottest = temperatures .iter() .filter_map(|t| t.temperature_c) - .fold(0f32, f32::max); - if hottest >= OVERTEMP_CUTOFF_C { + .fold(None, |acc: Option, t| Some(acc.map_or(t, |a| a.max(t)))); + if board_hottest.is_some() { + last_temp_at = std::time::Instant::now(); + } + + if let Some(t) = board_hottest + && t >= TMP75_OVERTEMP_C + { error!( board = %hashboard.index, - hottest = hottest, - cutoff = OVERTEMP_CUTOFF_C, + hottest = t, + cutoff = TMP75_OVERTEMP_C, "OVERTEMP — disabling PIC DC-DC and PSU output" ); if let Some(ref mut pic) = pic_for_heartbeat { @@ -1065,7 +1078,49 @@ async fn native_telemetry_task( break; } - let fans = read_fan_states(&config, config.startup.default_fan_percent).await; + let temp_stale = last_temp_at.elapsed() >= TEMP_STALE_AFTER; + let target_fan_percent: u8 = if temp_stale { + warn!( + board = %hashboard.index, + "No temperature sample in 30 s — pinning fans to 100 % as a safety fallback" + ); + 100 + } else if let Some(t) = board_hottest { + if t <= FAN_RAMP_START_C { + FAN_FLOOR_PERCENT + } else if t >= FAN_RAMP_FULL_C { + 100 + } else { + let span = FAN_RAMP_FULL_C - FAN_RAMP_START_C; + let into_ramp = t - FAN_RAMP_START_C; + let pct = FAN_FLOOR_PERCENT as f32 + + ((100.0 - FAN_FLOOR_PERCENT as f32) * (into_ramp / span)); + pct.round().clamp(FAN_FLOOR_PERCENT as f32, 100.0) as u8 + } + } else { + config.startup.default_fan_percent.max(FAN_FLOOR_PERCENT) + }; + + if Some(target_fan_percent) != applied_fan_percent { + if let Err(e) = configure_fans(&config, target_fan_percent) { + warn!( + board = %hashboard.index, + target_percent = target_fan_percent, + error = %e, + "configure_fans failed; previous duty still applied" + ); + } else { + info!( + board = %hashboard.index, + target_percent = target_fan_percent, + board_temp_c = board_hottest.unwrap_or(0.0), + "Adjusted fan PWM" + ); + applied_fan_percent = Some(target_fan_percent); + } + } + + let fans = read_fan_states(&config, target_fan_percent).await; let voltage_v = match psu.lock().await.measure_voltage() { Ok(voltage_v) => Some(voltage_v), Err(error) => { @@ -1237,10 +1292,20 @@ fn validate_i2c_address(address: u16) -> Result { } fn tmp75_addresses(board_index: u8) -> Result<[u8; 2], BoardError> { + // Per the Bitmain hardware mapping documented in + // `skot/amlogic-cb-tools::bin/hashboard_s19jpro.rs` (function + // `tmp75_addresses` and the `--help` banner): + // HB0 → 0x48, 0x4C + // HB1 → 0x4D, 0x49 + // HB2 → 0x4E, 0x4A + // mujina previously had HB0 and HB2 swapped, which silently sent + // the overtemp cutoff to read whichever board was wired to the + // OTHER slot's TMP75 pair — a real safety bug for any deployment + // not running on HB1. match board_index { - 0 => Ok([0x4E, 0x4A]), + 0 => Ok([0x48, 0x4C]), 1 => Ok([0x4D, 0x49]), - 2 => Ok([0x48, 0x4C]), + 2 => Ok([0x4E, 0x4A]), _ => Err(BoardError::InitializationFailed(format!( "invalid hashboard index: {board_index}" ))), diff --git a/mujina-miner/src/board/s19k_pro_amlogic.rs b/mujina-miner/src/board/s19k_pro_amlogic.rs index d4697c5e..4d2ec3b6 100644 --- a/mujina-miner/src/board/s19k_pro_amlogic.rs +++ b/mujina-miner/src/board/s19k_pro_amlogic.rs @@ -1231,10 +1231,15 @@ async fn native_telemetry_task( pics.push((pic_addr, hb.clone(), pic)); } - // Stock Bitmain firmware uses ~95 °C hard shutdown / ~85 °C throttle - // on this chip family. 75 °C is conservative for the bench setup; - // configurable threshold is future work. - const OVERTEMP_CUTOFF_C: f32 = 75.0; + // Tracks the last duty applied to the fans by the dynamic curve so we + // only call configure_fans on actual changes. None means "never set + // by this task yet" — the next iteration will issue the first write. + let mut applied_fan_percent: Option = None; + // Wall-clock of the last successful temperature read; the watchdog + // below pins fans to 100 % once this gets stale. Initialize to NOW + // so we tolerate the first read taking a little while before + // declaring the sensor dead. + let mut last_temp_at = std::time::Instant::now(); loop { if shutdown.is_cancelled() { @@ -1288,16 +1293,56 @@ async fn native_telemetry_task( } } - // Overtemp gate: ANY sensor on ANY hashboard above cutoff - // kills DC-DC on every PIC + cuts the shared PSU output. - let hottest = temperatures + // ----- Fan + overtemp control -------------------------------- + // + // The BHB56902 doesn't expose the BM1366's on-die thermal + // diode the way a Bitaxe does (which routes it to an EMC2101 + // fan controller, then reads it over i2c — see + // `bitaxeorg/ESP-Miner::main/thermal/EMC2101.{c,h}` and + // `Thermal_get_chip_temp` in main/thermal/thermal.c). The only + // signal we have on this hashboard is the pair of TMP75 + // sensors on the PCB, and they're poorly thermally coupled to + // the chips — a TMP75 reading of 30 °C can sit alongside a + // chip die at 80+ °C, which is exactly how two of our test + // boards smoked before this code existed. + // + // Strategy until a better signal arrives: + // + // 1. Drive a dynamic fan curve off the hottest available + // sensor across every present hashboard — multi-board + // mode aggregates all TMP75 readings into `temperatures`, + // so the curve naturally tracks whichever board runs + // hottest. Floor at 60 % so the cooling can't drop to + // nothing while the curve is still cold. + // 2. Hard cutoff at 65 °C TMP75 — sensor under-reads die by + // ~20 °C, so 65 here is ~85 °C actual die. + // 3. Watchdog: if we haven't even READ a temperature in 30 s + // (i2c stuck, sensor died, telemetry task stalled and we + // just got back), pin fans to 100 % until we recover. + // 4. The boot-time `default_fan_percent` from the toml is + // now 100 — the previous 50–60 % default was the + // open-loop value that ran during the entire pre-mining + // window with no temp signal at all. + const TMP75_OVERTEMP_C: f32 = 65.0; + const FAN_FLOOR_PERCENT: u8 = 60; + const FAN_RAMP_START_C: f32 = 40.0; + const FAN_RAMP_FULL_C: f32 = 60.0; + const TEMP_STALE_AFTER: Duration = Duration::from_secs(30); + + let board_hottest = temperatures .iter() .filter_map(|t| t.temperature_c) - .fold(0f32, f32::max); - if hottest >= OVERTEMP_CUTOFF_C { + .fold(None, |acc: Option, t| Some(acc.map_or(t, |a| a.max(t)))); + if board_hottest.is_some() { + last_temp_at = std::time::Instant::now(); + } + + if let Some(t) = board_hottest + && t >= TMP75_OVERTEMP_C + { error!( - hottest = hottest, - cutoff = OVERTEMP_CUTOFF_C, + hottest = t, + cutoff = TMP75_OVERTEMP_C, "OVERTEMP — disabling all PIC DC-DC outputs and PSU output" ); for (_, _, pic_opt) in pics.iter_mut() { @@ -1310,7 +1355,48 @@ async fn native_telemetry_task( break; } - let fans = read_fan_states(&config, config.startup.default_fan_percent).await; + let temp_stale = last_temp_at.elapsed() >= TEMP_STALE_AFTER; + let target_fan_percent: u8 = if temp_stale { + warn!( + "No temperature sample in 30 s — pinning fans to 100 % as a safety fallback" + ); + 100 + } else if let Some(t) = board_hottest { + if t <= FAN_RAMP_START_C { + FAN_FLOOR_PERCENT + } else if t >= FAN_RAMP_FULL_C { + 100 + } else { + let span = FAN_RAMP_FULL_C - FAN_RAMP_START_C; + let into_ramp = t - FAN_RAMP_START_C; + let pct = FAN_FLOOR_PERCENT as f32 + + ((100.0 - FAN_FLOOR_PERCENT as f32) * (into_ramp / span)); + pct.round().clamp(FAN_FLOOR_PERCENT as f32, 100.0) as u8 + } + } else { + // Pre-mining / first tick — sit at the boot value but + // never below the floor. + config.startup.default_fan_percent.max(FAN_FLOOR_PERCENT) + }; + + if Some(target_fan_percent) != applied_fan_percent { + if let Err(e) = configure_fans(&config, target_fan_percent) { + warn!( + target_percent = target_fan_percent, + error = %e, + "configure_fans failed; previous duty still applied" + ); + } else { + info!( + target_percent = target_fan_percent, + board_temp_c = board_hottest.unwrap_or(0.0), + "Adjusted fan PWM" + ); + applied_fan_percent = Some(target_fan_percent); + } + } + + let fans = read_fan_states(&config, target_fan_percent).await; let voltage_v = match psu.lock().await.measure_voltage() { Ok(voltage_v) => Some(voltage_v), Err(error) => { @@ -1482,10 +1568,20 @@ fn validate_i2c_address(address: u16) -> Result { } fn tmp75_addresses(board_index: u8) -> Result<[u8; 2], BoardError> { + // Per the Bitmain hardware mapping documented in + // `skot/amlogic-cb-tools::bin/hashboard_s19jpro.rs` (function + // `tmp75_addresses` and the `--help` banner): + // HB0 → 0x48, 0x4C + // HB1 → 0x4D, 0x49 + // HB2 → 0x4E, 0x4A + // mujina previously had HB0 and HB2 swapped, which silently sent + // the overtemp cutoff to read whichever board was wired to the + // OTHER slot's TMP75 pair — a real safety bug for any deployment + // not running on HB1. match board_index { - 0 => Ok([0x4E, 0x4A]), + 0 => Ok([0x48, 0x4C]), 1 => Ok([0x4D, 0x49]), - 2 => Ok([0x48, 0x4C]), + 2 => Ok([0x4E, 0x4A]), _ => Err(BoardError::InitializationFailed(format!( "invalid hashboard index: {board_index}" ))), diff --git a/mujina.toml b/mujina.toml index 70278971..97358c18 100644 --- a/mujina.toml +++ b/mujina.toml @@ -9,7 +9,7 @@ write_register = 17 enable_gpio = 437 [hardware.amlogic_control_board.startup] -default_fan_percent = 50 +default_fan_percent = 100 initial_voltage = 12.0 psu_settle_ms = 2000 reset_assert_ms = 100