mirror of
https://github.com/ilya-zlobintsev/LACT.git
synced 2025-02-25 18:55:26 -06:00
feat: initial support for RDNA3 fan configuration (PMFW) (#239)
* wip * feat: basic PMFW options gui * bump amdgpu-sysfs * test * test 2 * test 3 * revert test * fix: set pmfw settings last * fix: always set pmfw values * feat: reset pmfw button * fix: reset button placement * chore: cleanup * chore: bump version * feat: clean up pmfw settings on exit * fix
This commit is contained in:
parent
ce1cd56444
commit
2faea931d2
17
Cargo.lock
generated
17
Cargo.lock
generated
@ -41,9 +41,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "amdgpu-sysfs"
|
name = "amdgpu-sysfs"
|
||||||
version = "0.12.8"
|
version = "0.12.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "165e7e777966011248a311f2c2e96ec1242935da4b829ac2c9a615ae936c0640"
|
checksum = "b3a224a37eff351e9942de53949ad8822b0ef0e628a3fb41d3b78fd06f39ecfc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"enum_dispatch",
|
"enum_dispatch",
|
||||||
"serde",
|
"serde",
|
||||||
@ -1270,7 +1270,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lact"
|
name = "lact"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"lact-cli",
|
"lact-cli",
|
||||||
@ -1281,7 +1281,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lact-cli"
|
name = "lact-cli"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"lact-client",
|
"lact-client",
|
||||||
@ -1290,7 +1290,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lact-client"
|
name = "lact-client"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"lact-schema",
|
"lact-schema",
|
||||||
@ -1302,7 +1302,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lact-daemon"
|
name = "lact-daemon"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bincode",
|
"bincode",
|
||||||
@ -1327,7 +1327,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lact-gui"
|
name = "lact-gui"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"gtk4",
|
"gtk4",
|
||||||
@ -1341,13 +1341,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lact-schema"
|
name = "lact-schema"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"amdgpu-sysfs",
|
"amdgpu-sysfs",
|
||||||
"clap",
|
"clap",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_with",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lact-cli"
|
name = "lact-cli"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lact-client"
|
name = "lact-client"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
@ -10,8 +10,8 @@ use schema::{
|
|||||||
power_profile_mode::PowerProfileModesTable, PerformanceLevel, PowerLevelKind,
|
power_profile_mode::PowerProfileModesTable, PerformanceLevel, PowerLevelKind,
|
||||||
},
|
},
|
||||||
request::{ConfirmCommand, SetClocksCommand},
|
request::{ConfirmCommand, SetClocksCommand},
|
||||||
ClocksInfo, DeviceInfo, DeviceListEntry, DeviceStats, FanControlMode, FanCurveMap, PowerStates,
|
ClocksInfo, DeviceInfo, DeviceListEntry, DeviceStats, FanControlMode, FanCurveMap, PmfwOptions,
|
||||||
Request, Response, SystemInfo,
|
PowerStates, Request, Response, SystemInfo,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::{
|
use std::{
|
||||||
@ -111,6 +111,7 @@ impl DaemonClient {
|
|||||||
mode: Option<FanControlMode>,
|
mode: Option<FanControlMode>,
|
||||||
static_speed: Option<f64>,
|
static_speed: Option<f64>,
|
||||||
curve: Option<FanCurveMap>,
|
curve: Option<FanCurveMap>,
|
||||||
|
pmfw: PmfwOptions,
|
||||||
) -> anyhow::Result<u64> {
|
) -> anyhow::Result<u64> {
|
||||||
self.make_request(Request::SetFanControl {
|
self.make_request(Request::SetFanControl {
|
||||||
id,
|
id,
|
||||||
@ -118,6 +119,7 @@ impl DaemonClient {
|
|||||||
mode,
|
mode,
|
||||||
static_speed,
|
static_speed,
|
||||||
curve,
|
curve,
|
||||||
|
pmfw,
|
||||||
})?
|
})?
|
||||||
.inner()
|
.inner()
|
||||||
}
|
}
|
||||||
@ -138,6 +140,7 @@ impl DaemonClient {
|
|||||||
PowerProfileModesTable
|
PowerProfileModesTable
|
||||||
);
|
);
|
||||||
request_with_id!(get_power_states, GetPowerStates, PowerStates);
|
request_with_id!(get_power_states, GetPowerStates, PowerStates);
|
||||||
|
request_with_id!(reset_pmfw, ResetPmfw, u64);
|
||||||
|
|
||||||
pub fn set_performance_level(
|
pub fn set_performance_level(
|
||||||
&self,
|
&self,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lact-daemon"
|
name = "lact-daemon"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
@ -4,7 +4,7 @@ use lact_schema::{
|
|||||||
amdgpu_sysfs::gpu_handle::{PerformanceLevel, PowerLevelKind},
|
amdgpu_sysfs::gpu_handle::{PerformanceLevel, PowerLevelKind},
|
||||||
default_fan_curve,
|
default_fan_curve,
|
||||||
request::SetClocksCommand,
|
request::SetClocksCommand,
|
||||||
FanControlMode,
|
FanControlMode, PmfwOptions,
|
||||||
};
|
};
|
||||||
use nix::unistd::getuid;
|
use nix::unistd::getuid;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@ -56,6 +56,8 @@ impl Default for Daemon {
|
|||||||
pub struct Gpu {
|
pub struct Gpu {
|
||||||
pub fan_control_enabled: bool,
|
pub fan_control_enabled: bool,
|
||||||
pub fan_control_settings: Option<FanControlSettings>,
|
pub fan_control_settings: Option<FanControlSettings>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub pmfw_options: PmfwOptions,
|
||||||
pub power_cap: Option<f64>,
|
pub power_cap: Option<f64>,
|
||||||
pub performance_level: Option<PerformanceLevel>,
|
pub performance_level: Option<PerformanceLevel>,
|
||||||
#[serde(default, flatten)]
|
#[serde(default, flatten)]
|
||||||
@ -179,10 +181,9 @@ fn default_apply_settings_timer() -> u64 {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use lact_schema::{FanControlMode, PmfwOptions};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use lact_schema::FanControlMode;
|
|
||||||
|
|
||||||
use super::{ClocksConfiguration, Config, Daemon, FanControlSettings, Gpu};
|
use super::{ClocksConfiguration, Config, Daemon, FanControlSettings, Gpu};
|
||||||
use crate::server::gpu_controller::fan_control::FanCurve;
|
use crate::server::gpu_controller::fan_control::FanCurve;
|
||||||
|
|
||||||
@ -217,6 +218,7 @@ mod tests {
|
|||||||
let mut gpu = Gpu {
|
let mut gpu = Gpu {
|
||||||
fan_control_enabled: false,
|
fan_control_enabled: false,
|
||||||
fan_control_settings: None,
|
fan_control_settings: None,
|
||||||
|
pmfw_options: PmfwOptions::default(),
|
||||||
power_cap: None,
|
power_cap: None,
|
||||||
performance_level: None,
|
performance_level: None,
|
||||||
clocks_configuration: ClocksConfiguration::default(),
|
clocks_configuration: ClocksConfiguration::default(),
|
||||||
|
@ -18,7 +18,7 @@ use lact_schema::{
|
|||||||
sysfs::SysFS,
|
sysfs::SysFS,
|
||||||
},
|
},
|
||||||
ClocksInfo, ClockspeedStats, DeviceInfo, DeviceStats, DrmInfo, FanStats, GpuPciInfo, LinkInfo,
|
ClocksInfo, ClockspeedStats, DeviceInfo, DeviceStats, DrmInfo, FanStats, GpuPciInfo, LinkInfo,
|
||||||
PciInfo, PowerState, PowerStates, PowerStats, VoltageStats, VramStats,
|
PciInfo, PmfwInfo, PowerState, PowerStates, PowerStats, VoltageStats, VramStats,
|
||||||
};
|
};
|
||||||
use pciid_parser::Database;
|
use pciid_parser::Database;
|
||||||
use std::{
|
use std::{
|
||||||
@ -262,6 +262,12 @@ impl GpuController {
|
|||||||
speed_current: self.hw_mon_and_then(HwMon::get_fan_current),
|
speed_current: self.hw_mon_and_then(HwMon::get_fan_current),
|
||||||
speed_max: self.hw_mon_and_then(HwMon::get_fan_max),
|
speed_max: self.hw_mon_and_then(HwMon::get_fan_max),
|
||||||
speed_min: self.hw_mon_and_then(HwMon::get_fan_min),
|
speed_min: self.hw_mon_and_then(HwMon::get_fan_min),
|
||||||
|
pmfw_info: PmfwInfo {
|
||||||
|
acoustic_limit: self.handle.get_fan_acoustic_limit().ok(),
|
||||||
|
acoustic_target: self.handle.get_fan_acoustic_target().ok(),
|
||||||
|
target_temp: self.handle.get_fan_target_temperature().ok(),
|
||||||
|
minimum_pwm: self.handle.get_fan_minimum_pwm().ok(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
clockspeed: ClockspeedStats {
|
clockspeed: ClockspeedStats {
|
||||||
gpu_clockspeed: self.hw_mon_and_then(HwMon::get_gpu_clockspeed),
|
gpu_clockspeed: self.hw_mon_and_then(HwMon::get_gpu_clockspeed),
|
||||||
@ -468,6 +474,30 @@ impl GpuController {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn reset_pmfw_settings(&self) {
|
||||||
|
let handle = &self.handle;
|
||||||
|
if self.handle.get_fan_target_temperature().is_ok() {
|
||||||
|
if let Err(err) = handle.reset_fan_target_temperature() {
|
||||||
|
warn!("Could not reset target temperature: {err:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.handle.get_fan_acoustic_target().is_ok() {
|
||||||
|
if let Err(err) = handle.reset_fan_acoustic_target() {
|
||||||
|
warn!("Could not reset acoustic target: {err:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.handle.get_fan_acoustic_limit().is_ok() {
|
||||||
|
if let Err(err) = handle.reset_fan_acoustic_limit() {
|
||||||
|
warn!("Could not reset acoustic limit: {err:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.handle.get_fan_minimum_pwm().is_ok() {
|
||||||
|
if let Err(err) = handle.reset_fan_minimum_pwm() {
|
||||||
|
warn!("Could not reset minimum pwm: {err:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn apply_config(&self, config: &config::Gpu) -> anyhow::Result<()> {
|
pub async fn apply_config(&self, config: &config::Gpu) -> anyhow::Result<()> {
|
||||||
if config.fan_control_enabled {
|
if config.fan_control_enabled {
|
||||||
if let Some(ref settings) = config.fan_control_settings {
|
if let Some(ref settings) = config.fan_control_settings {
|
||||||
@ -598,6 +628,30 @@ impl GpuController {
|
|||||||
.with_context(|| format!("Could not set {kind:?} power states"))?;
|
.with_context(|| format!("Could not set {kind:?} power states"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !config.fan_control_enabled {
|
||||||
|
let pmfw = &config.pmfw_options;
|
||||||
|
if let Some(acoustic_limit) = pmfw.acoustic_limit {
|
||||||
|
self.handle
|
||||||
|
.set_fan_acoustic_limit(acoustic_limit)
|
||||||
|
.context("Could not set acoustic limit")?;
|
||||||
|
}
|
||||||
|
if let Some(acoustic_target) = pmfw.acoustic_target {
|
||||||
|
self.handle
|
||||||
|
.set_fan_acoustic_target(acoustic_target)
|
||||||
|
.context("Could not set acoustic target")?;
|
||||||
|
}
|
||||||
|
if let Some(target_temperature) = pmfw.target_temperature {
|
||||||
|
self.handle
|
||||||
|
.set_fan_target_temperature(target_temperature)
|
||||||
|
.context("Could not set target temperature")?;
|
||||||
|
}
|
||||||
|
if let Some(minimum_pwm) = pmfw.minimum_pwm {
|
||||||
|
self.handle
|
||||||
|
.set_fan_minimum_pwm(minimum_pwm)
|
||||||
|
.context("Could not set minimum pwm")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,7 +13,8 @@ use lact_schema::{
|
|||||||
},
|
},
|
||||||
default_fan_curve,
|
default_fan_curve,
|
||||||
request::{ConfirmCommand, SetClocksCommand},
|
request::{ConfirmCommand, SetClocksCommand},
|
||||||
ClocksInfo, DeviceInfo, DeviceListEntry, DeviceStats, FanControlMode, FanCurveMap, PowerStates,
|
ClocksInfo, DeviceInfo, DeviceListEntry, DeviceStats, FanControlMode, FanCurveMap, PmfwOptions,
|
||||||
|
PowerStates,
|
||||||
};
|
};
|
||||||
use std::{
|
use std::{
|
||||||
cell::RefCell,
|
cell::RefCell,
|
||||||
@ -263,6 +264,7 @@ impl<'a> Handler {
|
|||||||
mode: Option<FanControlMode>,
|
mode: Option<FanControlMode>,
|
||||||
static_speed: Option<f64>,
|
static_speed: Option<f64>,
|
||||||
curve: Option<FanCurveMap>,
|
curve: Option<FanCurveMap>,
|
||||||
|
pmfw: PmfwOptions,
|
||||||
) -> anyhow::Result<u64> {
|
) -> anyhow::Result<u64> {
|
||||||
let settings = {
|
let settings = {
|
||||||
let mut config_guard = self
|
let mut config_guard = self
|
||||||
@ -323,6 +325,17 @@ impl<'a> Handler {
|
|||||||
if let Some(settings) = settings {
|
if let Some(settings) = settings {
|
||||||
config.fan_control_settings = Some(settings);
|
config.fan_control_settings = Some(settings);
|
||||||
}
|
}
|
||||||
|
config.pmfw_options = pmfw;
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reset_pmfw(&self, id: &str) -> anyhow::Result<u64> {
|
||||||
|
info!("Resetting PMFW settings");
|
||||||
|
self.controller_by_id(id)?.reset_pmfw_settings();
|
||||||
|
|
||||||
|
self.edit_gpu_config(id.to_owned(), |config| {
|
||||||
|
config.pmfw_options = PmfwOptions::default();
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@ -510,6 +523,8 @@ impl<'a> Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
controller.reset_pmfw_settings();
|
||||||
|
|
||||||
if let Err(err) = controller.apply_config(&config::Gpu::default()).await {
|
if let Err(err) = controller.apply_config(&config::Gpu::default()).await {
|
||||||
error!("Could not reset settings for controller {id}: {err:#}");
|
error!("Could not reset settings for controller {id}: {err:#}");
|
||||||
}
|
}
|
||||||
|
@ -92,11 +92,13 @@ async fn handle_request<'a>(request: Request<'a>, handler: &'a Handler) -> anyho
|
|||||||
mode,
|
mode,
|
||||||
static_speed,
|
static_speed,
|
||||||
curve,
|
curve,
|
||||||
|
pmfw,
|
||||||
} => ok_response(
|
} => ok_response(
|
||||||
handler
|
handler
|
||||||
.set_fan_control(id, enabled, mode, static_speed, curve)
|
.set_fan_control(id, enabled, mode, static_speed, curve, pmfw)
|
||||||
.await?,
|
.await?,
|
||||||
),
|
),
|
||||||
|
Request::ResetPmfw { id } => ok_response(handler.reset_pmfw(id).await?),
|
||||||
Request::SetPowerCap { id, cap } => ok_response(handler.set_power_cap(id, cap).await?),
|
Request::SetPowerCap { id, cap } => ok_response(handler.set_power_cap(id, cap).await?),
|
||||||
Request::SetPerformanceLevel {
|
Request::SetPerformanceLevel {
|
||||||
id,
|
id,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lact-gui"
|
name = "lact-gui"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
authors = ["Ilya Zlobintsev <ilya.zl@protonmail.com>"]
|
authors = ["Ilya Zlobintsev <ilya.zl@protonmail.com>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
|
@ -125,6 +125,23 @@ impl App {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
app.root_stack.thermals_page.connect_reset_pmfw(clone!(@strong app, @strong current_gpu_id => move || {
|
||||||
|
debug!("Resetting PMFW settings");
|
||||||
|
let gpu_id = current_gpu_id.borrow().clone();
|
||||||
|
|
||||||
|
match app.daemon_client.reset_pmfw(&gpu_id)
|
||||||
|
.and_then(|buffer| buffer.inner())
|
||||||
|
.and_then(|_| app.daemon_client.confirm_pending_config(ConfirmCommand::Confirm))
|
||||||
|
{
|
||||||
|
Ok(()) => {
|
||||||
|
app.set_initial(&gpu_id);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
show_error(&app.window, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
app.apply_revealer.connect_apply_button_clicked(
|
app.apply_revealer.connect_apply_button_clicked(
|
||||||
clone!(@strong app, @strong current_gpu_id => move || {
|
clone!(@strong app, @strong current_gpu_id => move || {
|
||||||
glib::idle_add_local_once(clone!(@strong app, @strong current_gpu_id => move || {
|
glib::idle_add_local_once(clone!(@strong app, @strong current_gpu_id => move || {
|
||||||
@ -389,6 +406,7 @@ impl App {
|
|||||||
thermals_settings.mode,
|
thermals_settings.mode,
|
||||||
thermals_settings.static_speed,
|
thermals_settings.static_speed,
|
||||||
thermals_settings.curve,
|
thermals_settings.curve,
|
||||||
|
thermals_settings.pmfw,
|
||||||
)
|
)
|
||||||
.context("Could not set fan control")?;
|
.context("Could not set fan control")?;
|
||||||
self.daemon_client
|
self.daemon_client
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
mod info_page;
|
mod info_page;
|
||||||
|
mod oc_adjustment;
|
||||||
mod oc_page;
|
mod oc_page;
|
||||||
mod software_page;
|
mod software_page;
|
||||||
mod thermals_page;
|
mod thermals_page;
|
||||||
|
@ -2,7 +2,7 @@ mod imp;
|
|||||||
|
|
||||||
use glib::Object;
|
use glib::Object;
|
||||||
use gtk::{
|
use gtk::{
|
||||||
glib::{self},
|
glib::{self, ObjectExt},
|
||||||
subclass::prelude::*,
|
subclass::prelude::*,
|
||||||
traits::AdjustmentExt,
|
traits::AdjustmentExt,
|
||||||
};
|
};
|
||||||
@ -57,9 +57,19 @@ impl OcAdjustment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_nonzero_value(&self) -> Option<f64> {
|
||||||
|
let value = self.value();
|
||||||
|
if value == 0.0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_initial_value(&self, value: f64) {
|
pub fn set_initial_value(&self, value: f64) {
|
||||||
let inner = self.imp();
|
let inner = self.imp();
|
||||||
inner.obj().set_value(value);
|
inner.obj().set_value(value);
|
||||||
|
inner.obj().emit_by_name::<()>("value_changed", &[]);
|
||||||
inner.changed.store(false, Ordering::SeqCst);
|
inner.changed.store(false, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,6 +1,5 @@
|
|||||||
mod clocks_frame;
|
mod clocks_frame;
|
||||||
mod gpu_stats_section;
|
mod gpu_stats_section;
|
||||||
mod oc_adjustment;
|
|
||||||
mod performance_frame;
|
mod performance_frame;
|
||||||
mod power_cap_section;
|
mod power_cap_section;
|
||||||
// mod power_cap_frame;
|
// mod power_cap_frame;
|
||||||
|
@ -29,7 +29,7 @@ impl Default for PowerCapSection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mod imp {
|
mod imp {
|
||||||
use crate::app::{page_section::PageSection, root_stack::oc_page::oc_adjustment::OcAdjustment};
|
use crate::app::{page_section::PageSection, root_stack::oc_adjustment::OcAdjustment};
|
||||||
use gtk::{
|
use gtk::{
|
||||||
glib::{self, clone, subclass::InitializingObject, Properties, StaticTypeExt},
|
glib::{self, clone, subclass::InitializingObject, Properties, StaticTypeExt},
|
||||||
prelude::{ButtonExt, ObjectExt},
|
prelude::{ButtonExt, ObjectExt},
|
||||||
|
@ -1,15 +1,16 @@
|
|||||||
mod fan_curve_frame;
|
mod fan_curve_frame;
|
||||||
|
mod pmfw_frame;
|
||||||
|
|
||||||
use glib::clone;
|
use glib::clone;
|
||||||
use gtk::prelude::*;
|
use gtk::prelude::*;
|
||||||
use gtk::*;
|
use gtk::*;
|
||||||
use lact_client::schema::{default_fan_curve, DeviceStats, FanControlMode, FanCurveMap};
|
use lact_client::schema::{
|
||||||
|
default_fan_curve, DeviceStats, FanControlMode, FanCurveMap, PmfwOptions,
|
||||||
use crate::app::page_section::PageSection;
|
};
|
||||||
|
|
||||||
use self::fan_curve_frame::FanCurveFrame;
|
|
||||||
|
|
||||||
|
use self::{fan_curve_frame::FanCurveFrame, pmfw_frame::PmfwFrame};
|
||||||
use super::{label_row, values_grid};
|
use super::{label_row, values_grid};
|
||||||
|
use crate::app::page_section::PageSection;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ThermalsSettings {
|
pub struct ThermalsSettings {
|
||||||
@ -17,6 +18,7 @@ pub struct ThermalsSettings {
|
|||||||
pub mode: Option<FanControlMode>,
|
pub mode: Option<FanControlMode>,
|
||||||
pub static_speed: Option<f64>,
|
pub static_speed: Option<f64>,
|
||||||
pub curve: Option<FanCurveMap>,
|
pub curve: Option<FanCurveMap>,
|
||||||
|
pub pmfw: PmfwOptions,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@ -24,6 +26,7 @@ pub struct ThermalsPage {
|
|||||||
pub container: Box,
|
pub container: Box,
|
||||||
temperatures_label: Label,
|
temperatures_label: Label,
|
||||||
fan_speed_label: Label,
|
fan_speed_label: Label,
|
||||||
|
pmfw_frame: PmfwFrame,
|
||||||
fan_static_speed_adjustment: Adjustment,
|
fan_static_speed_adjustment: Adjustment,
|
||||||
fan_curve_frame: FanCurveFrame,
|
fan_curve_frame: FanCurveFrame,
|
||||||
fan_control_mode_stack: Stack,
|
fan_control_mode_stack: Stack,
|
||||||
@ -58,6 +61,8 @@ impl ThermalsPage {
|
|||||||
.build();
|
.build();
|
||||||
let fan_static_speed_adjustment = static_speed_adj(&fan_static_speed_frame);
|
let fan_static_speed_adjustment = static_speed_adj(&fan_static_speed_frame);
|
||||||
|
|
||||||
|
let pmfw_frame = PmfwFrame::new();
|
||||||
|
|
||||||
let fan_control_section = PageSection::new("Fan control");
|
let fan_control_section = PageSection::new("Fan control");
|
||||||
|
|
||||||
let fan_control_mode_stack = Stack::builder().build();
|
let fan_control_mode_stack = Stack::builder().build();
|
||||||
@ -67,14 +72,8 @@ impl ThermalsPage {
|
|||||||
.sensitive(false)
|
.sensitive(false)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
fan_control_mode_stack.add_titled(
|
fan_control_mode_stack.add_titled(&pmfw_frame.container, Some("automatic"), "Automatic");
|
||||||
&Box::new(Orientation::Vertical, 15),
|
|
||||||
Some("automatic"),
|
|
||||||
"Automatic",
|
|
||||||
);
|
|
||||||
|
|
||||||
fan_control_mode_stack.add_titled(&fan_curve_frame.container, Some("curve"), "Curve");
|
fan_control_mode_stack.add_titled(&fan_curve_frame.container, Some("curve"), "Curve");
|
||||||
|
|
||||||
fan_control_mode_stack.add_titled(&fan_static_speed_frame, Some("static"), "Static");
|
fan_control_mode_stack.add_titled(&fan_static_speed_frame, Some("static"), "Static");
|
||||||
|
|
||||||
fan_control_section.append(&fan_control_mode_stack_switcher);
|
fan_control_section.append(&fan_control_mode_stack_switcher);
|
||||||
@ -96,6 +95,7 @@ impl ThermalsPage {
|
|||||||
fan_curve_frame,
|
fan_curve_frame,
|
||||||
fan_control_mode_stack,
|
fan_control_mode_stack,
|
||||||
fan_control_mode_stack_switcher,
|
fan_control_mode_stack_switcher,
|
||||||
|
pmfw_frame,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -155,6 +155,8 @@ impl ThermalsPage {
|
|||||||
if !stats.fan.control_enabled && self.fan_curve_frame.get_curve().is_empty() {
|
if !stats.fan.control_enabled && self.fan_curve_frame.get_curve().is_empty() {
|
||||||
self.fan_curve_frame.set_curve(&default_fan_curve());
|
self.fan_curve_frame.set_curve(&default_fan_curve());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.pmfw_frame.set_info(&stats.fan.pmfw_info);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,6 +171,8 @@ impl ThermalsPage {
|
|||||||
f();
|
f();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
self.pmfw_frame.connect_settings_changed(f.clone());
|
||||||
|
|
||||||
self.fan_curve_frame.connect_adjusted(move || {
|
self.fan_curve_frame.connect_adjusted(move || {
|
||||||
f();
|
f();
|
||||||
});
|
});
|
||||||
@ -191,16 +195,23 @@ impl ThermalsPage {
|
|||||||
let curve = self.fan_curve_frame.get_curve();
|
let curve = self.fan_curve_frame.get_curve();
|
||||||
let curve = if curve.is_empty() { None } else { Some(curve) };
|
let curve = if curve.is_empty() { None } else { Some(curve) };
|
||||||
|
|
||||||
|
let pmfw = self.pmfw_frame.get_pmfw_options();
|
||||||
|
|
||||||
Some(ThermalsSettings {
|
Some(ThermalsSettings {
|
||||||
manual_fan_control,
|
manual_fan_control,
|
||||||
mode,
|
mode,
|
||||||
static_speed,
|
static_speed,
|
||||||
curve,
|
curve,
|
||||||
|
pmfw,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn connect_reset_pmfw<F: Fn() + 'static + Clone>(&self, f: F) {
|
||||||
|
self.pmfw_frame.connect_reset(f);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn static_speed_adj(parent_box: &Box) -> Adjustment {
|
fn static_speed_adj(parent_box: &Box) -> Adjustment {
|
||||||
|
179
lact-gui/src/app/root_stack/thermals_page/pmfw_frame.rs
Normal file
179
lact-gui/src/app/root_stack/thermals_page/pmfw_frame.rs
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
use crate::app::root_stack::oc_adjustment::OcAdjustment;
|
||||||
|
use gtk::{
|
||||||
|
glib::clone,
|
||||||
|
prelude::{AdjustmentExt, ButtonExt, GridExt, WidgetExt},
|
||||||
|
Align, Button, Grid, Label, MenuButton, Orientation, Popover, Scale, SpinButton,
|
||||||
|
};
|
||||||
|
use lact_client::schema::{amdgpu_sysfs::gpu_handle::fan_control::FanInfo, PmfwInfo, PmfwOptions};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PmfwFrame {
|
||||||
|
pub container: Grid,
|
||||||
|
target_temperature: OcAdjustment,
|
||||||
|
acoustic_limit: OcAdjustment,
|
||||||
|
acoustic_target: OcAdjustment,
|
||||||
|
minimum_pwm: OcAdjustment,
|
||||||
|
reset_button: Button,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PmfwFrame {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let grid = Grid::builder()
|
||||||
|
.orientation(Orientation::Vertical)
|
||||||
|
.row_spacing(5)
|
||||||
|
.margin_top(10)
|
||||||
|
.margin_bottom(10)
|
||||||
|
.margin_start(10)
|
||||||
|
.margin_end(10)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let target_temperature = adjustment(&grid, "Target temperature (°C)", 0);
|
||||||
|
let acoustic_limit = adjustment(&grid, "Acoustic limit (RPM)", 1);
|
||||||
|
let acoustic_target = adjustment(&grid, "Acoustic target (RPM)", 2);
|
||||||
|
let minimum_pwm = adjustment(&grid, "Minimum fan speed (%)", 3);
|
||||||
|
|
||||||
|
let reset_button = Button::builder()
|
||||||
|
.label("Reset")
|
||||||
|
.halign(Align::Fill)
|
||||||
|
.margin_top(5)
|
||||||
|
.margin_bottom(5)
|
||||||
|
.tooltip_text("Warning: this resets the fan firmware settings!")
|
||||||
|
.css_classes(["destructive-action"])
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
grid.attach(&reset_button, 5, 4, 1, 1);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
container: grid,
|
||||||
|
target_temperature,
|
||||||
|
acoustic_limit,
|
||||||
|
acoustic_target,
|
||||||
|
minimum_pwm,
|
||||||
|
reset_button,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_info(&self, info: &PmfwInfo) {
|
||||||
|
set_fan_info(&self.acoustic_limit, info.acoustic_limit);
|
||||||
|
set_fan_info(&self.acoustic_target, info.acoustic_target);
|
||||||
|
set_fan_info(&self.minimum_pwm, info.minimum_pwm);
|
||||||
|
set_fan_info(&self.target_temperature, info.target_temp);
|
||||||
|
|
||||||
|
let settings_available = *info != PmfwInfo::default();
|
||||||
|
self.reset_button.set_visible(settings_available);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connect_settings_changed<F: Fn() + 'static + Clone>(&self, f: F) {
|
||||||
|
self.acoustic_limit
|
||||||
|
.connect_value_changed(clone!(@strong f => move |_| {
|
||||||
|
f();
|
||||||
|
}));
|
||||||
|
self.acoustic_target
|
||||||
|
.connect_value_changed(clone!(@strong f => move |_| {
|
||||||
|
f();
|
||||||
|
}));
|
||||||
|
self.minimum_pwm
|
||||||
|
.connect_value_changed(clone!(@strong f => move |_| {
|
||||||
|
f();
|
||||||
|
}));
|
||||||
|
self.target_temperature
|
||||||
|
.connect_value_changed(clone!(@strong f => move |_| {
|
||||||
|
f();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connect_reset<F: Fn() + 'static + Clone>(&self, f: F) {
|
||||||
|
self.reset_button.connect_clicked(move |_| {
|
||||||
|
f();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_pmfw_options(&self) -> PmfwOptions {
|
||||||
|
PmfwOptions {
|
||||||
|
acoustic_limit: self
|
||||||
|
.acoustic_limit
|
||||||
|
.get_nonzero_value()
|
||||||
|
.map(|value| value as u32),
|
||||||
|
acoustic_target: self
|
||||||
|
.acoustic_target
|
||||||
|
.get_nonzero_value()
|
||||||
|
.map(|value| value as u32),
|
||||||
|
minimum_pwm: self
|
||||||
|
.minimum_pwm
|
||||||
|
.get_nonzero_value()
|
||||||
|
.map(|value| value as u32),
|
||||||
|
target_temperature: self
|
||||||
|
.target_temperature
|
||||||
|
.get_nonzero_value()
|
||||||
|
.map(|value| value as u32),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_fan_info(adjustment: &OcAdjustment, info: Option<FanInfo>) {
|
||||||
|
match info {
|
||||||
|
Some(info) => {
|
||||||
|
if let Some((min, max)) = info.allowed_range {
|
||||||
|
adjustment.set_lower(min as f64);
|
||||||
|
adjustment.set_upper(max as f64);
|
||||||
|
} else {
|
||||||
|
adjustment.set_lower(0.0);
|
||||||
|
adjustment.set_upper(info.current as f64);
|
||||||
|
}
|
||||||
|
|
||||||
|
adjustment.set_initial_value(info.current as f64);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
adjustment.set_upper(0.0);
|
||||||
|
adjustment.set_initial_value(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn adjustment(parent_grid: &Grid, label: &str, row: i32) -> OcAdjustment {
|
||||||
|
let label = Label::builder().label(label).halign(Align::Start).build();
|
||||||
|
|
||||||
|
let adjustment = OcAdjustment::new(0.0, 0.0, 100.0, 1.0, 1.0, 0.0);
|
||||||
|
|
||||||
|
let scale = Scale::builder()
|
||||||
|
.orientation(Orientation::Horizontal)
|
||||||
|
.adjustment(&adjustment)
|
||||||
|
.hexpand(true)
|
||||||
|
.margin_start(5)
|
||||||
|
.margin_end(5)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let value_selector = SpinButton::new(Some(&adjustment), 1.0, 0);
|
||||||
|
let value_label = Label::new(None);
|
||||||
|
|
||||||
|
let popover = Popover::builder().child(&value_selector).build();
|
||||||
|
let value_button = MenuButton::builder()
|
||||||
|
.popover(&popover)
|
||||||
|
.child(&value_label)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
adjustment.connect_value_changed(
|
||||||
|
clone!(@strong value_label, @strong label, @strong scale, @strong value_button => move |adjustment| {
|
||||||
|
let value = adjustment.value();
|
||||||
|
value_label.set_text(&format!("{}", value as u32));
|
||||||
|
|
||||||
|
if adjustment.upper() == 0.0 {
|
||||||
|
label.hide();
|
||||||
|
value_label.hide();
|
||||||
|
scale.hide();
|
||||||
|
value_button.hide();
|
||||||
|
} else {
|
||||||
|
label.show();
|
||||||
|
value_label.show();
|
||||||
|
scale.show();
|
||||||
|
value_button.show();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
parent_grid.attach(&label, 0, row, 1, 1);
|
||||||
|
parent_grid.attach(&scale, 1, row, 4, 1);
|
||||||
|
parent_grid.attach(&value_button, 5, row, 1, 1);
|
||||||
|
|
||||||
|
adjustment
|
||||||
|
}
|
@ -1,16 +1,19 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lact-schema"
|
name = "lact-schema"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
args = ["clap"]
|
args = ["clap"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
amdgpu-sysfs = { version = "0.12.7", features = ["serde"] }
|
amdgpu-sysfs = { version = "0.12.9", features = ["serde"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
indexmap = { version = "*", features = ["serde"] }
|
indexmap = { version = "*", features = ["serde"] }
|
||||||
clap = { version = "4.4.11", features = ["derive"], optional = true }
|
clap = { version = "4.4.11", features = ["derive"], optional = true }
|
||||||
|
serde_with = { version = "3.4.0", default-features = false, features = [
|
||||||
|
"macros",
|
||||||
|
] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
@ -12,6 +12,7 @@ pub use response::Response;
|
|||||||
|
|
||||||
use amdgpu_sysfs::{
|
use amdgpu_sysfs::{
|
||||||
gpu_handle::{
|
gpu_handle::{
|
||||||
|
fan_control::FanInfo,
|
||||||
overdrive::{ClocksTable, ClocksTableGen},
|
overdrive::{ClocksTable, ClocksTableGen},
|
||||||
PerformanceLevel,
|
PerformanceLevel,
|
||||||
},
|
},
|
||||||
@ -19,6 +20,7 @@ use amdgpu_sysfs::{
|
|||||||
};
|
};
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_with::skip_serializing_none;
|
||||||
use std::{
|
use std::{
|
||||||
borrow::Cow,
|
borrow::Cow,
|
||||||
collections::{BTreeMap, HashMap},
|
collections::{BTreeMap, HashMap},
|
||||||
@ -195,6 +197,18 @@ pub struct FanStats {
|
|||||||
pub speed_current: Option<u32>,
|
pub speed_current: Option<u32>,
|
||||||
pub speed_max: Option<u32>,
|
pub speed_max: Option<u32>,
|
||||||
pub speed_min: Option<u32>,
|
pub speed_min: Option<u32>,
|
||||||
|
// RDNA3+ params
|
||||||
|
#[serde(default)]
|
||||||
|
pub pmfw_info: PmfwInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[skip_serializing_none]
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub struct PmfwInfo {
|
||||||
|
pub acoustic_limit: Option<FanInfo>,
|
||||||
|
pub acoustic_target: Option<FanInfo>,
|
||||||
|
pub target_temp: Option<FanInfo>,
|
||||||
|
pub minimum_pwm: Option<FanInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
|
||||||
@ -248,3 +262,12 @@ pub enum InitramfsType {
|
|||||||
Debian,
|
Debian,
|
||||||
Mkinitcpio,
|
Mkinitcpio,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[skip_serializing_none]
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub struct PmfwOptions {
|
||||||
|
pub acoustic_limit: Option<u32>,
|
||||||
|
pub acoustic_target: Option<u32>,
|
||||||
|
pub minimum_pwm: Option<u32>,
|
||||||
|
pub target_temperature: Option<u32>,
|
||||||
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
use crate::{FanControlMode, FanCurveMap};
|
use crate::{FanControlMode, FanCurveMap, PmfwOptions};
|
||||||
use amdgpu_sysfs::gpu_handle::{PerformanceLevel, PowerLevelKind};
|
use amdgpu_sysfs::gpu_handle::{PerformanceLevel, PowerLevelKind};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@ -26,6 +26,11 @@ pub enum Request<'a> {
|
|||||||
mode: Option<FanControlMode>,
|
mode: Option<FanControlMode>,
|
||||||
static_speed: Option<f64>,
|
static_speed: Option<f64>,
|
||||||
curve: Option<FanCurveMap>,
|
curve: Option<FanCurveMap>,
|
||||||
|
#[serde(default)]
|
||||||
|
pmfw: PmfwOptions,
|
||||||
|
},
|
||||||
|
ResetPmfw {
|
||||||
|
id: &'a str,
|
||||||
},
|
},
|
||||||
SetPowerCap {
|
SetPowerCap {
|
||||||
id: &'a str,
|
id: &'a str,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lact"
|
name = "lact"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
@ -3,7 +3,7 @@ metadata:
|
|||||||
description: AMDGPU control utility
|
description: AMDGPU control utility
|
||||||
arch: x86_64
|
arch: x86_64
|
||||||
license: MIT
|
license: MIT
|
||||||
version: 0.5.1
|
version: 0.5.2
|
||||||
maintainer: ilya-zlobintsev
|
maintainer: ilya-zlobintsev
|
||||||
url: https://github.com/ilya-zlobintsev/lact
|
url: https://github.com/ilya-zlobintsev/lact
|
||||||
source:
|
source:
|
||||||
|
@ -3,7 +3,7 @@ metadata:
|
|||||||
description: AMDGPU control utility
|
description: AMDGPU control utility
|
||||||
arch: x86_64
|
arch: x86_64
|
||||||
license: MIT
|
license: MIT
|
||||||
version: 0.5.1
|
version: 0.5.2
|
||||||
maintainer: ilya-zlobintsev
|
maintainer: ilya-zlobintsev
|
||||||
url: https://github.com/ilya-zlobintsev/lact
|
url: https://github.com/ilya-zlobintsev/lact
|
||||||
source:
|
source:
|
||||||
|
@ -3,7 +3,7 @@ metadata:
|
|||||||
description: AMDGPU control utility
|
description: AMDGPU control utility
|
||||||
arch: x86_64
|
arch: x86_64
|
||||||
license: MIT
|
license: MIT
|
||||||
version: 0.5.1
|
version: 0.5.2
|
||||||
maintainer: ilya-zlobintsev
|
maintainer: ilya-zlobintsev
|
||||||
url: https://github.com/ilya-zlobintsev/lact
|
url: https://github.com/ilya-zlobintsev/lact
|
||||||
source:
|
source:
|
||||||
|
Loading…
Reference in New Issue
Block a user