diff --git a/README.md b/README.md index d17ef053..83f90a73 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,16 @@ running. That HB2 config now targets the corrected native mapping for hashboard 2: `/dev/ttyS1` with reset GPIO `456`, detect GPIO `441`, TMP75 addresses -`0x4C/0x48`, and EEPROM address `0x50`. +`0x4E/0x4A` on `/dev/i2c-1`, and EEPROM address `0x52` on `/dev/i2c-1`. + +The Amlogic configs also support a `startup.fan_control` PID loop. The current +S19j Pro profiles enable it with a `60C` target and duty-cycle clamps so the +board can regulate fan speed from the TMP75 readings instead of staying fixed +at the startup PWM percentage. + +`/home/root/start.sh` now sources `/home/root/mujina.env` first when present, +so pool credentials, API bind address, and log level can be adjusted without +editing the wrapper itself. A template lives at [`mujina.env.example`](mujina.env.example). ### GT Touch USB Display diff --git a/mujina-hb2.toml b/mujina-hb2.toml index 4f4d3182..659fb75c 100644 --- a/mujina-hb2.toml +++ b/mujina-hb2.toml @@ -15,6 +15,16 @@ psu_settle_ms = 2000 reset_assert_ms = 100 reset_release_ms = 2000 +[hardware.amlogic_control_board.startup.fan_control] +enabled = true +target_temp_c = 60.0 +min_percent = 35 +max_percent = 100 +kp = 3.0 +ki = 0.15 +kd = 8.0 +integral_limit = 200.0 + [hardware.amlogic_control_board.startup.health_gate] read_eeprom_before_mining = true read_temperatures_before_mining = true @@ -65,8 +75,8 @@ model = "s19j_pro" serial_path = "/dev/ttyS1" reset_gpio = 456 detect_gpio = 441 -temp_i2c_device = "/dev/i2c-0" -temp_sensor_addresses = [76, 72] -eeprom_i2c_device = "/dev/i2c-0" -eeprom_address = 80 +temp_i2c_device = "/dev/i2c-1" +temp_sensor_addresses = [78, 74] +eeprom_i2c_device = "/dev/i2c-1" +eeprom_address = 82 required = true diff --git a/mujina-miner/src/asic/bm13xx/thread_v2.rs b/mujina-miner/src/asic/bm13xx/thread_v2.rs index 2190ebfc..22a6c6cd 100644 --- a/mujina-miner/src/asic/bm13xx/thread_v2.rs +++ b/mujina-miner/src/asic/bm13xx/thread_v2.rs @@ -44,6 +44,25 @@ fn min_viable_chip_count(expected: usize) -> usize { expected / 2 } +fn configured_target_frequency() -> protocol::Frequency { + const DEFAULT_TARGET_FREQ_MHZ: f32 = 500.0; + + match std::env::var("MUJINA_TARGET_FREQ_MHZ") { + Ok(raw) => match raw.parse::() { + Ok(mhz) if mhz.is_finite() && mhz > 0.0 => protocol::Frequency::from_mhz(mhz), + Ok(_) | Err(_) => { + warn!( + value = %raw, + default_mhz = DEFAULT_TARGET_FREQ_MHZ, + "Invalid MUJINA_TARGET_FREQ_MHZ; using default" + ); + protocol::Frequency::from_mhz(DEFAULT_TARGET_FREQ_MHZ) + } + }, + Err(_) => protocol::Frequency::from_mhz(DEFAULT_TARGET_FREQ_MHZ), + } +} + /// [`HashThread`] implementation for BM13xx ASIC chains. /// /// The scheduler uses this to dispatch mining work to BM13xx chips. @@ -663,8 +682,8 @@ where self.execute_reg_config_perchip().await?; // 7. Ramp frequency to target (flat voltage throughout) - self.execute_frequency_ramp(protocol::Frequency::from_mhz(500.0)) - .await?; + let target_frequency = configured_target_frequency(); + self.execute_frequency_ramp(target_frequency).await?; self.chip_state = ChipState::Initialized; let status = self.update_status(|status| { diff --git a/mujina-miner/src/board/s19j_pro_amlogic.rs b/mujina-miner/src/board/s19j_pro_amlogic.rs index bf5f16d1..31c31e57 100644 --- a/mujina-miner/src/board/s19j_pro_amlogic.rs +++ b/mujina-miner/src/board/s19j_pro_amlogic.rs @@ -41,7 +41,7 @@ use crate::{ HashThreadEvent, HashThreadStatus, }, }, - config::{AmlogicControlBoardConfig, AmlogicHashboardConfig}, + config::{AmlogicControlBoardConfig, AmlogicFanControlConfig, AmlogicHashboardConfig}, error::Error, tracing::prelude::*, transport::serial::SerialStream, @@ -79,6 +79,7 @@ pub struct S19jProAmlogic { selected_hashboard: AmlogicHashboardConfig, board_serial: Option, psu: Arc>, + psu_online: bool, state_tx: watch::Sender, thread_states: Arc>>, telemetry_shutdown: CancellationToken, @@ -90,6 +91,7 @@ impl S19jProAmlogic { selected_hashboard: AmlogicHashboardConfig, board_serial: Option, psu: Arc>, + psu_online: bool, state_tx: watch::Sender, ) -> Self { Self { @@ -97,6 +99,7 @@ impl S19jProAmlogic { selected_hashboard, board_serial, psu, + psu_online, state_tx, thread_states: Arc::new(std::sync::Mutex::new(Vec::new())), telemetry_shutdown: CancellationToken::new(), @@ -111,6 +114,7 @@ impl S19jProAmlogic { AmlogicHashboardConfig, Option, Arc>, + bool, ), BoardError, > { @@ -131,23 +135,32 @@ impl S19jProAmlogic { assert_all_resets(config)?; let psu = Arc::new(Mutex::new(NativeAmlogicPsu::new(config))); - let measured_voltage = { + let (measured_voltage, psu_online) = { let mut psu_guard = psu.lock().await; 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}")) - })?; - 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() + match psu_guard.config_watchdog(0x00) { + Ok(()) => { + psu_guard + .set_voltage(config.startup.initial_voltage) + .await + .map_err(|e| { + BoardError::HardwareControl(format!("Failed to set PSU voltage: {e}")) + })?; + (psu_guard.measure_voltage().ok(), true) + } + Err(error) => { + warn!( + board = %board_name, + error = %error, + "APW12 control path unavailable; continuing with GPIO-only PSU enable" + ); + (None, false) + } + } }; let fan_states = build_fan_state(config, config.startup.default_fan_percent); @@ -167,7 +180,7 @@ impl S19jProAmlogic { state.powers = power_states.clone(); }); - Ok((selected_hashboard, board_serial, psu)) + Ok((selected_hashboard, board_serial, psu, psu_online)) } } @@ -224,9 +237,9 @@ impl Board for S19jProAmlogic { 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> - ), + voltage_regulator: self + .psu_online + .then(|| Arc::clone(&self.psu) as Arc>), }, }; @@ -269,7 +282,7 @@ impl Board for S19jProAmlogic { let config = self.config.clone(); let hashboard = self.selected_hashboard.clone(); - let psu = Arc::clone(&self.psu); + let psu = self.psu_online.then(|| 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(); @@ -398,6 +411,65 @@ struct NativeResetControl { reset_release_ms: u64, } +struct AmlogicFanController { + config: AmlogicFanControlConfig, + baseline_percent: u8, + previous_temp_c: Option, + integral_error: f32, +} + +impl AmlogicFanController { + fn new(config: &AmlogicControlBoardConfig) -> Self { + Self { + config: config.startup.fan_control.clone(), + baseline_percent: config.startup.default_fan_percent, + previous_temp_c: None, + integral_error: 0.0, + } + } + + fn is_enabled(&self) -> bool { + self.config.enabled + } + + fn target_percent(&mut self, temperatures: &[TemperatureSensor], interval: Duration) -> u8 { + if !self.config.enabled { + return self.baseline_percent; + } + + let max_temp_c = temperatures + .iter() + .filter_map(|sensor| sensor.temperature_c) + .reduce(f32::max); + + let Some(max_temp_c) = max_temp_c else { + self.previous_temp_c = None; + self.integral_error = 0.0; + return self.config.max_percent; + }; + + let interval_secs = interval.as_secs_f32().max(0.001); + let error = max_temp_c - self.config.target_temp_c; + self.integral_error = (self.integral_error + error * interval_secs) + .clamp(-self.config.integral_limit, self.config.integral_limit); + let derivative = self + .previous_temp_c + .map(|previous| (max_temp_c - previous) / interval_secs) + .unwrap_or(0.0); + self.previous_temp_c = Some(max_temp_c); + + let pid_output = self.baseline_percent as f32 + + (self.config.kp * error) + + (self.config.ki * self.integral_error) + + (self.config.kd * derivative); + + pid_output.round().clamp( + self.config.min_percent as f32, + self.config.max_percent as f32, + ) as u8 + } +} + #[async_trait] impl AsicEnable for NativeResetControl { async fn enable(&mut self) -> anyhow::Result<()> { @@ -688,12 +760,14 @@ fn build_fan_state(config: &AmlogicControlBoardConfig, percent: u8) -> Vec async fn native_telemetry_task( config: AmlogicControlBoardConfig, hashboard: AmlogicHashboardConfig, - psu: Arc>, + psu: Option>>, state_tx: watch::Sender, thread_states: Arc>>, shutdown: CancellationToken, ) { const TELEMETRY_INTERVAL: Duration = Duration::from_secs(2); + let mut fan_controller = AmlogicFanController::new(&config); + let mut applied_fan_percent = config.startup.default_fan_percent; loop { if shutdown.is_cancelled() { @@ -708,13 +782,43 @@ async fn native_telemetry_task( } }; - 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 requested_fan_percent = + fan_controller.target_percent(&temperatures, TELEMETRY_INTERVAL); + if requested_fan_percent != applied_fan_percent { + if let Err(error) = configure_fans(&config, requested_fan_percent) { + warn!( + percent = requested_fan_percent, + error = %error, + "Failed to apply native Amlogic fan target" + ); + } else { + if fan_controller.is_enabled() { + let peak_temp_c = temperatures + .iter() + .filter_map(|sensor| sensor.temperature_c) + .reduce(f32::max); + debug!( + percent = requested_fan_percent, + target_temp_c = fan_controller.config.target_temp_c, + peak_temp_c, + "Updated native Amlogic fan target" + ); + } + applied_fan_percent = requested_fan_percent; } + } + + let fans = read_fan_states(&config, applied_fan_percent).await; + let voltage_v = if let Some(psu) = &psu { + match psu.lock().await.measure_voltage() { + Ok(voltage_v) => Some(voltage_v), + Err(error) => { + debug!(error = %error, "Native telemetry PSU voltage read failed"); + None + } + } + } else { + None }; let powers = vec![PowerMeasurement { name: "apw12".into(), @@ -773,7 +877,7 @@ async fn read_fan_states(config: &AmlogicControlBoardConfig, target_percent: u8) fan_states.push(Fan { name: fan_name, rpm, - percent: None, + percent: Some(target_percent), target_percent: Some(target_percent), }); } @@ -941,11 +1045,21 @@ async fn create_amlogic_board() }; let (state_tx, state_rx) = watch::channel(initial_state); - let (selected_hashboard, board_serial, psu) = S19jProAmlogic::initialize(&config, &state_tx) - .await - .map_err(|e| Error::Hardware(format!("Failed to initialize native Amlogic board: {e}")))?; + let (selected_hashboard, board_serial, psu, psu_online) = + S19jProAmlogic::initialize(&config, &state_tx) + .await + .map_err(|e| { + Error::Hardware(format!("Failed to initialize native Amlogic board: {e}")) + })?; - let board = S19jProAmlogic::new(config, selected_hashboard, board_serial, psu, state_tx); + let board = S19jProAmlogic::new( + config, + selected_hashboard, + board_serial, + psu, + psu_online, + state_tx, + ); let registration = super::BoardRegistration { state_rx }; Ok((Box::new(board), registration)) } @@ -957,3 +1071,106 @@ inventory::submit! { create_fn: || Box::pin(create_amlogic_board()), } } + +#[cfg(test)] +mod tests { + use super::AmlogicFanController; + use crate::{ + api_client::types::TemperatureSensor, + config::{ + AmlogicControlBoardConfig, AmlogicFanConfig, AmlogicHashboardConfig, + AmlogicHealthGateConfig, AmlogicPsuConfig, AmlogicStartupConfig, + }, + }; + use std::{path::PathBuf, time::Duration}; + + fn test_config() -> AmlogicControlBoardConfig { + AmlogicControlBoardConfig { + enabled: true, + board_name: Some("test".into()), + psu: AmlogicPsuConfig { + i2c_device: PathBuf::from("/dev/null"), + address: 0x10, + write_register: 0x11, + enable_gpio: 1, + }, + startup: AmlogicStartupConfig { + default_fan_percent: 50, + fan_control: crate::config::AmlogicFanControlConfig { + enabled: true, + target_temp_c: 60.0, + min_percent: 35, + max_percent: 100, + kp: 3.0, + ki: 0.15, + kd: 8.0, + integral_limit: 200.0, + }, + initial_voltage: 12.0, + psu_settle_ms: 100, + reset_assert_ms: 100, + reset_release_ms: 100, + health_gate: AmlogicHealthGateConfig { + read_eeprom_before_mining: false, + read_temperatures_before_mining: false, + fail_on_missing_expected_hashboard: false, + }, + }, + fans: vec![AmlogicFanConfig { + index: 0, + pwm_chip: 0, + pwm_channel: 0, + tach_gpio: 1, + pulses_per_rev: 2, + }], + leds: None, + hashboards: vec![AmlogicHashboardConfig { + index: 2, + model: crate::config::HashboardModel::S19jPro, + serial_path: PathBuf::from("/dev/null"), + reset_gpio: 1, + detect_gpio: 2, + temp_i2c_device: PathBuf::from("/dev/null"), + temp_sensor_addresses: Vec::new(), + eeprom_i2c_device: PathBuf::from("/dev/null"), + eeprom_address: None, + required: false, + }], + gt_touch_display: None, + } + } + + fn sensor(temp_c: f32) -> TemperatureSensor { + TemperatureSensor { + name: "temp".into(), + temperature_c: Some(temp_c), + } + } + + #[test] + fn pid_falls_back_to_max_on_missing_temperature() { + let mut controller = AmlogicFanController::new(&test_config()); + + assert_eq!(controller.target_percent(&[], Duration::from_secs(2)), 100); + } + + #[test] + fn pid_increases_fan_above_target_temperature() { + let mut controller = AmlogicFanController::new(&test_config()); + + assert_eq!( + controller.target_percent(&[sensor(70.0)], Duration::from_secs(2)), + 80 + ); + } + + #[test] + fn pid_respects_minimum_fan_floor_below_target_temperature() { + let mut controller = AmlogicFanController::new(&test_config()); + + assert_eq!( + controller.target_percent(&[sensor(35.0)], Duration::from_secs(2)), + 35 + ); + } +} diff --git a/mujina-miner/src/config.rs b/mujina-miner/src/config.rs index f7a54842..2a02339d 100644 --- a/mujina-miner/src/config.rs +++ b/mujina-miner/src/config.rs @@ -194,6 +194,10 @@ pub struct AmlogicStartupConfig { /// Default fan duty cycle applied before ASIC bring-up. pub default_fan_percent: u8, + /// Runtime fan control policy after bring-up completes. + #[serde(default)] + pub fan_control: AmlogicFanControlConfig, + /// Initial PSU output voltage used for first BM1362 enumeration. /// /// This should be a low bring-up voltage. The BM13xx thread ramps the PSU @@ -214,6 +218,50 @@ pub struct AmlogicStartupConfig { pub health_gate: AmlogicHealthGateConfig, } +/// Runtime fan-control policy for the native Amlogic board path. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default)] +pub struct AmlogicFanControlConfig { + /// Enable closed-loop fan control using the hashboard temperature sensors. + pub enabled: bool, + + /// Desired maximum operating temperature in degrees Celsius. + pub target_temp_c: f32, + + /// Minimum fan duty cycle the controller is allowed to command. + pub min_percent: u8, + + /// Maximum fan duty cycle the controller is allowed to command. + pub max_percent: u8, + + /// Proportional gain in percent per degree Celsius of error. + pub kp: f32, + + /// Integral gain in percent per degree Celsius-second of accumulated error. + pub ki: f32, + + /// Derivative gain in percent per degree Celsius per second of temperature change. + pub kd: f32, + + /// Clamp for the integral accumulator to avoid wind-up. + pub integral_limit: f32, +} + +impl Default for AmlogicFanControlConfig { + fn default() -> Self { + Self { + enabled: false, + target_temp_c: 60.0, + min_percent: 35, + max_percent: 100, + kp: 3.0, + ki: 0.15, + kd: 8.0, + integral_limit: 200.0, + } + } +} + /// Pre-mining validation policy. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AmlogicHealthGateConfig { diff --git a/mujina.env.example b/mujina.env.example new file mode 100644 index 00000000..0eb5790e --- /dev/null +++ b/mujina.env.example @@ -0,0 +1,9 @@ +# Copy this file to /home/root/mujina.env and adjust values as needed. + +MUJINA_LOG_LEVEL=debug +MUJINA_CONFIG=/home/root/mujina-hb2.toml +MUJINA_POOL_URL=stratum+tcp://pool.256foundation.org:3333 +MUJINA_POOL_USER=npub1ql2zzp3g6yndgz05js7wdc4qkr88wkyne5nw2cc7csrtzqs0yeesgwrxya.mujina-jPro-amlogic +MUJINA_POOL_PASS=x +MUJINA_API_LISTEN=0.0.0.0:7785 +MUJINA_TARGET_FREQ_MHZ=500 diff --git a/mujina.toml b/mujina.toml index 70278971..b9b223bb 100644 --- a/mujina.toml +++ b/mujina.toml @@ -15,6 +15,16 @@ psu_settle_ms = 2000 reset_assert_ms = 100 reset_release_ms = 2000 +[hardware.amlogic_control_board.startup.fan_control] +enabled = true +target_temp_c = 60.0 +min_percent = 35 +max_percent = 100 +kp = 3.0 +ki = 0.15 +kd = 8.0 +integral_limit = 200.0 + [hardware.amlogic_control_board.startup.health_gate] read_eeprom_before_mining = true read_temperatures_before_mining = true @@ -58,8 +68,8 @@ model = "s19j_pro" serial_path = "/dev/ttyS3" reset_gpio = 454 detect_gpio = 439 -temp_i2c_device = "/dev/i2c-0" -eeprom_i2c_device = "/dev/i2c-0" +temp_i2c_device = "/dev/i2c-1" +eeprom_i2c_device = "/dev/i2c-1" required = false [[hardware.amlogic_control_board.hashboards]] @@ -68,8 +78,8 @@ model = "s19j_pro" serial_path = "/dev/ttyS2" reset_gpio = 455 detect_gpio = 440 -temp_i2c_device = "/dev/i2c-0" -eeprom_i2c_device = "/dev/i2c-0" +temp_i2c_device = "/dev/i2c-1" +eeprom_i2c_device = "/dev/i2c-1" required = false [[hardware.amlogic_control_board.hashboards]] @@ -78,6 +88,6 @@ model = "s19j_pro" serial_path = "/dev/ttyS1" reset_gpio = 456 detect_gpio = 441 -temp_i2c_device = "/dev/i2c-0" -eeprom_i2c_device = "/dev/i2c-0" -required = false \ No newline at end of file +temp_i2c_device = "/dev/i2c-1" +eeprom_i2c_device = "/dev/i2c-1" +required = false diff --git a/start.sh b/start.sh index 072dc2ec..7dc95129 100644 --- a/start.sh +++ b/start.sh @@ -2,9 +2,22 @@ set -eu -RUST_LOG=debug \ -MUJINA_CONFIG=/home/root/mujina-hb2.toml \ -MUJINA_POOL_URL='stratum+tcp://pool.256foundation.org:3333' \ -MUJINA_POOL_USER='npub1ql2zzp3g6yndgz05js7wdc4qkr88wkyne5nw2cc7csrtzqs0yeesgwrxya.mujina-jPro-amlogic' \ -MUJINA_API_LISTEN='0.0.0.0:7785' \ -/home/root/mujina-minerd \ No newline at end of file +[ -f /home/root/mujina.env ] && . /home/root/mujina.env + +: "${MUJINA_LOG_LEVEL:=debug}" +: "${MUJINA_CONFIG:=/home/root/mujina-hb2.toml}" +: "${MUJINA_POOL_URL:=stratum+tcp://pool.256foundation.org:3333}" +: "${MUJINA_POOL_USER:=npub1ql2zzp3g6yndgz05js7wdc4qkr88wkyne5nw2cc7csrtzqs0yeesgwrxya.mujina-jPro-amlogic}" +: "${MUJINA_POOL_PASS:=x}" +: "${MUJINA_API_LISTEN:=0.0.0.0:7785}" +: "${MUJINA_TARGET_FREQ_MHZ:=500}" + +export RUST_LOG="${RUST_LOG:-${MUJINA_LOG_LEVEL}}" +export MUJINA_CONFIG +export MUJINA_POOL_URL +export MUJINA_POOL_USER +export MUJINA_POOL_PASS +export MUJINA_API_LISTEN +export MUJINA_TARGET_FREQ_MHZ + +exec /home/root/mujina-minerd