Add manual and battery-aware automatic display refresh switching

This commit is contained in:
Samuel Onoja committed 2026-10-01 08:08:08 +01:00
1 parent a203b45a4b
commit a02925b2ee
8 files changed
+515 -5

No files matched your search

+74 -5
View File
@@ -41,6 +41,7 @@ pub struct DirectBackendState {
pub session: LibSeatSession,
pub active: bool,
lid_closed: bool,
pub(crate) low_power: bool,
devices: HashMap<DrmNode, DirectDevice>,
input_devices: Vec<LibinputDevice>,
presentation: HashMap<(DrmNode, crtc::Handle), PresentationClock>,
@@ -119,6 +120,7 @@ pub(crate) struct ConnectedOutputInfo {
pub identity: String,
pub enabled: bool,
pub profile: Option<String>,
pub auto_refresh: bool,
pub physical_size: Option<(u32, u32)>,
pub current_mode: Option<ConnectedModeInfo>,
pub available_modes: Vec<ConnectedModeInfo>,
@@ -201,6 +203,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
session,
active: session_active,
lid_closed: initial_lid_closed(),
low_power: super::power::low_power(false, super::power::battery_percent()),
devices: HashMap::new(),
input_devices: Vec::new(),
presentation: HashMap::new(),
@@ -209,6 +212,12 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
animation_active: false,
});
open_primary_device(event_loop, state, &primary_path)?;
event_loop
.handle()
.insert_source(Timer::from_duration(Duration::from_secs(5)), |_, _, state| {
update_power_policy(state);
TimeoutAction::ToDuration(Duration::from_secs(5))
})?;
event_loop
.handle()
@@ -261,6 +270,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
}
}
}
update_power_policy(state);
state.reset_animation_clock();
tracing::info!("direct session activated");
state.lock_input_activity();
@@ -328,7 +338,38 @@ pub(crate) fn reload_input_devices(state: &mut Ferese) {
}
}
fn update_power_policy(state: &mut Ferese) {
if !state
.output_profiles
.iter()
.any(|profile| profile.outputs.iter().any(|output| output.auto_refresh))
{
return;
}
let Some(backend) = state.direct_backend.as_mut() else {
return;
};
// Keep low_power as the applied policy while paused. A transition sampled
// during inactivity must still be detected and applied on activation.
let battery = if backend.active {
super::power::battery_percent()
} else {
None
};
if let Some(next) = super::power::policy_change(backend.active, backend.low_power, battery) {
backend.low_power = next;
reload_outputs(state);
}
}
pub(crate) fn reload_outputs(state: &mut Ferese) {
if let Some(backend) = state.direct_backend.as_mut() {
let enabled = state
.output_profiles
.iter()
.any(|profile| profile.outputs.iter().any(|output| output.auto_refresh));
backend.low_power = enabled && super::power::low_power(backend.low_power, super::power::battery_percent());
}
let nodes = state
.direct_backend
.as_ref()
@@ -345,7 +386,7 @@ pub(crate) fn validate_live_outputs(state: &Ferese, profiles: &[OutputProfile])
};
let mut usable = 0;
for device in backend.devices.values() {
let mut scan = select_outputs(&device.drm, profiles).map_err(|e| e.to_string())?;
let mut scan = select_outputs(&device.drm, profiles, backend.low_power).map_err(|e| e.to_string())?;
apply_lid_policy(&mut scan, backend.lid_closed);
usable += scan.selections.len();
}
@@ -390,7 +431,11 @@ fn open_primary_device(
state
.dmabuf_state
.create_global_with_default_feedback::<Ferese>(&display_handle, &feedback);
let mut scan = select_outputs(&drm, &state.output_profiles)?;
let mut scan = select_outputs(
&drm,
&state.output_profiles,
state.direct_backend.as_ref().is_some_and(|backend| backend.low_power),
)?;
apply_lid_policy(
&mut scan,
state.direct_backend.as_ref().is_some_and(|backend| backend.lid_closed),
@@ -787,7 +832,11 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
return;
};
let mut scan = match select_outputs(&device.drm, &state.output_profiles) {
let mut scan = match select_outputs(
&device.drm,
&state.output_profiles,
state.direct_backend.as_ref().is_some_and(|backend| backend.low_power),
) {
Ok(scan) => scan,
Err(error) => {
tracing::error!(?node, %error, "failed to scan DRM connectors");
@@ -984,6 +1033,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
{
info.enabled = true;
info.current_mode = Some(connected_mode_info(output.mode));
info.auto_refresh = output.settings.auto_refresh;
info.scale = output.settings.scale;
info.transform = output.settings.transform;
info.configured_position = output.settings.position;
@@ -1188,7 +1238,7 @@ fn send_frame_callbacks(state: &mut Ferese, output: &Output) {
}
}
fn select_outputs(drm: &DrmDevice, profiles: &[OutputProfile]) -> io::Result<OutputScan> {
fn select_outputs(drm: &DrmDevice, profiles: &[OutputProfile], low_power: bool) -> io::Result<OutputScan> {
let resources = drm.resource_handles()?;
let connected = resources
.connectors()
@@ -1230,13 +1280,31 @@ fn select_outputs(drm: &DrmDevice, profiles: &[OutputProfile]) -> io::Result<Out
.unwrap_or_else(|| default_output_settings(connector.to_string()));
let selected_mode = settings
.enabled
.then(|| select_mode(&connector, settings.mode))
.then(|| {
let normal = select_mode(&connector, settings.mode)?;
if settings.auto_refresh && low_power {
let modes = connector
.modes()
.iter()
.map(|mode| {
let (width, height) = mode.size();
(width, height, OutputMode::from(*mode).refresh)
})
.collect::<Vec<_>>();
super::power::low_refresh_mode(normal.size(), &modes)
.map(|index| connector.modes()[index])
.or(Some(normal))
} else {
Some(normal)
}
})
.flatten();
connected_outputs.push(ConnectedOutputInfo {
connector: connector.to_string(),
identity: identity.clone(),
enabled: settings.enabled,
profile: active_profile.map(|profile| profile.name.clone()),
auto_refresh: settings.auto_refresh,
physical_size: connector.size(),
current_mode: selected_mode.map(connected_mode_info),
available_modes: connector.modes().iter().copied().map(connected_mode_info).collect(),
@@ -1298,6 +1366,7 @@ fn output_matches(settings: &OutputSettings, connector: &connector::Info, identi
fn default_output_settings(matcher: String) -> OutputSettings {
OutputSettings {
auto_refresh: false,
matcher,
enabled: true,
mode: None,
+1
View File
@@ -1,4 +1,5 @@
pub(crate) mod direct;
mod power;
use std::ffi::OsString;
+137
View File
@@ -0,0 +1,137 @@
//! Power state for automatic display refresh rates. Device batteries are excluded.
use std::path::Path;
pub(super) fn battery_percent() -> Option<u8> {
read_battery_percent(Path::new("/sys/class/power_supply"))
}
fn read_battery_percent(root: &Path) -> Option<u8> {
let mut entries = std::fs::read_dir(root)
.ok()?
.flatten()
.map(|entry| entry.path())
.collect::<Vec<_>>();
entries.sort();
let read = |path: &Path, name: &str| {
std::fs::read_to_string(path.join(name))
.ok()
.map(|text| text.trim().to_owned())
};
if entries
.iter()
.any(|path| read(path, "online").as_deref() == Some("1") && read(path, "type").as_deref() != Some("Battery"))
{
return None;
}
entries.iter().find_map(|path| {
if read(path, "type").as_deref() != Some("Battery")
|| read(path, "scope").as_deref() == Some("Device")
|| read(path, "status").as_deref() != Some("Discharging")
{
return None;
}
read(path, "capacity")?
.parse::<u8>()
.ok()
.filter(|percent| *percent <= 100)
})
}
pub(super) fn low_power(previous: bool, battery: Option<u8>) -> bool {
battery.is_some_and(|percent| if previous { percent < 35 } else { percent < 30 })
}
/// Return a new policy only when an active session needs a mode transition.
/// Inactive samples leave the applied policy intact until activation.
pub(super) fn policy_change(active: bool, applied: bool, battery: Option<u8>) -> Option<bool> {
if !active {
return None;
}
let next = low_power(applied, battery);
(next != applied).then_some(next)
}
/// Keep resolution unchanged and use a supported mode within 1 Hz of 60 Hz.
/// Panels without such a mode retain their normal refresh rate.
pub(super) fn low_refresh_mode(size: (u16, u16), modes: &[(u16, u16, i32)]) -> Option<usize> {
modes
.iter()
.enumerate()
.filter(|(_, (width, height, refresh))| (*width, *height) == size && (59_000..=61_000).contains(refresh))
.min_by_key(|(_, (_, _, refresh))| refresh.abs_diff(60_000))
.map(|(index, _)| index)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn activation_rescans_only_for_a_changed_policy() {
assert_eq!(policy_change(true, false, Some(50)), None);
assert_eq!(policy_change(true, true, Some(32)), None);
assert_eq!(policy_change(true, false, Some(29)), Some(true));
assert_eq!(policy_change(true, true, None), Some(false));
}
#[test]
fn paused_samples_defer_changes_until_activation() {
assert_eq!(policy_change(false, false, Some(20)), None);
assert_eq!(policy_change(true, false, Some(20)), Some(true));
assert_eq!(policy_change(false, true, None), None);
assert_eq!(policy_change(true, true, None), Some(false));
// A round trip across the threshold while paused needs no rescan.
assert_eq!(policy_change(false, false, Some(20)), None);
assert_eq!(policy_change(true, false, Some(50)), None);
}
#[test]
fn low_refresh_selection_rejects_unsupported_rates_and_preserves_resolution() {
let modes = [
(2880, 1800, 120_000),
(1920, 1080, 60_000),
(2880, 1800, 59_940),
(2880, 1800, 60_000),
];
assert_eq!(low_refresh_mode((2880, 1800), &modes), Some(3));
assert_eq!(low_refresh_mode((2880, 1800), &modes[..3]), Some(2));
assert_eq!(low_refresh_mode((2880, 1800), &modes[..2]), None);
assert_eq!(low_refresh_mode((2880, 1800), &[(2880, 1800, -60_000)]), None);
}
#[test]
fn thresholds_have_hysteresis_and_ac_or_unknown_restore_normal() {
assert!(low_power(false, Some(29)));
assert!(!low_power(false, Some(30)));
assert!(low_power(true, Some(34)));
assert!(!low_power(true, Some(35)));
assert!(!low_power(true, None));
}
#[test]
fn only_discharging_system_batteries_count_and_ac_has_priority() {
let root = tempfile::tempdir().unwrap();
let battery = root.path().join("BAT0");
std::fs::create_dir(&battery).unwrap();
for (name, value) in [
("type", "Battery"),
("scope", "System"),
("status", "Discharging"),
("capacity", "20"),
] {
std::fs::write(battery.join(name), value).unwrap();
}
assert_eq!(read_battery_percent(root.path()), Some(20));
std::fs::write(battery.join("scope"), "Device").unwrap();
assert_eq!(read_battery_percent(root.path()), None);
std::fs::write(battery.join("scope"), "System").unwrap();
std::fs::write(battery.join("capacity"), "bad").unwrap();
assert_eq!(read_battery_percent(root.path()), None);
std::fs::write(battery.join("capacity"), "20").unwrap();
let ac = root.path().join("AC");
std::fs::create_dir(&ac).unwrap();
std::fs::write(ac.join("type"), "Mains").unwrap();
std::fs::write(ac.join("online"), "1").unwrap();
assert_eq!(read_battery_percent(root.path()), None);
}
}
+15
View File
@@ -74,6 +74,7 @@ pub struct OutputProfile {
#[derive(Clone, Debug, PartialEq)]
pub struct OutputSettings {
pub auto_refresh: bool,
pub matcher: String,
pub enabled: bool,
pub mode: Option<OutputModeRequest>,
@@ -118,6 +119,8 @@ struct OutputProfileConfig {
#[derive(Clone, Debug, Deserialize)]
struct OutputConfig {
#[serde(default)]
auto_refresh: bool,
#[serde(rename = "match")]
matcher: String,
#[serde(default = "default_true")]
@@ -1139,6 +1142,7 @@ impl Config {
}
Ok(OutputSettings {
auto_refresh: output.auto_refresh,
matcher: matcher.to_owned(),
enabled: output.enabled,
mode: output
@@ -2448,6 +2452,7 @@ mod tests {
name: "docked".to_owned(),
outputs: vec![
OutputSettings {
auto_refresh: false,
matcher: "HDMI-A-1".to_owned(),
enabled: true,
mode: Some(OutputModeRequest {
@@ -2460,6 +2465,7 @@ mod tests {
position: Some([0, 0]),
},
OutputSettings {
auto_refresh: false,
matcher: "eDP-1".to_owned(),
enabled: false,
mode: None,
@@ -2472,6 +2478,15 @@ mod tests {
);
}
#[test]
fn automatic_refresh_is_opt_in_and_rejects_wrong_types() {
let config = parse("output-profile laptop { output eDP-1 mode=\"2880x1800@120\" auto-refresh=#true; }");
assert!(config.output_profiles().unwrap()[0].outputs[0].auto_refresh);
let manual = parse("output-profile laptop { output eDP-1 mode=\"2880x1800@60\"; }");
assert!(!manual.output_profiles().unwrap()[0].outputs[0].auto_refresh);
assert!(Config::parse_source("output-profile laptop { output eDP-1 auto-refresh=\"yes\"; }").is_err());
}
#[test]
fn rejects_invalid_output_profiles() {
let invalid_scale = parse("output-profile name=\"bad\" {\n output match=\"eDP-1\" scale=0.0\n}\n");
+2
View File
@@ -1045,6 +1045,8 @@ impl Ferese {
"connected": true,
"enabled": info.enabled && mapped.is_some(),
"profile": info.profile,
"auto_refresh": info.auto_refresh,
"low_power": backend.low_power,
"focused": id.is_some() && id == focused,
"workspace": workspace.map(|workspace| workspace.0),
"x": position.map(|position| position[0]),