From c0d40372376e1ff154dfae0ea814a30b7639dabd Mon Sep 17 00:00:00 2001 From: ssupt <149246609+ssupt@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:35:34 +0200 Subject: [PATCH] Support external monitor brightness in Quattro (#6490) * Support external monitor brightness Route brightness through the focused Hyprland monitor so internal panels keep using the kernel backlight while compatible external displays use DDC/CI. Preserve the Apple Display backend and leave brightness unavailable when the focused display cannot be controlled. Add cached DDC bus and VCP range handling, install ddcutil for new and existing systems, and cover backend selection and brightness conversion with shell tests. * Harden external brightness caching --------- Co-authored-by: David Heinemeier Hansson --- bin/omarchy-brightness-display | 60 ++++++-- bin/omarchy-brightness-display-ddc | 168 +++++++++++++++++++++ bin/omarchy-hyprland-monitor-focused-apple | 11 +- bin/omarchy-monitor-state | 7 +- install/omarchy-base.packages | 1 + migrations/1785608251.sh | 3 + shell/plugins/panels/monitor/Panel.qml | 2 +- test/shell.d/brightness-display-test.sh | 146 ++++++++++++++++++ 8 files changed, 379 insertions(+), 19 deletions(-) create mode 100755 bin/omarchy-brightness-display-ddc create mode 100644 migrations/1785608251.sh create mode 100755 test/shell.d/brightness-display-test.sh diff --git a/bin/omarchy-brightness-display b/bin/omarchy-brightness-display index 241ee091..a6a1e830 100755 --- a/bin/omarchy-brightness-display +++ b/bin/omarchy-brightness-display @@ -1,28 +1,59 @@ #!/bin/bash -# omarchy:summary=Show or adjust brightness on the most likely display device. -# omarchy:args=[--no-osd] [+N%|N%-|N%|off|on] -# omarchy:examples=omarchy brightness display | omarchy brightness display +5% | omarchy brightness display --no-osd 50% | omarchy brightness display off | omarchy brightness display on +# omarchy:summary=Show or adjust brightness on the focused display. +# omarchy:args=[--no-osd] [--monitor name] [+N%|N%-|N%|off|on] +# omarchy:examples=omarchy brightness display | omarchy brightness display +5% | omarchy brightness display --monitor DP-1 50% | omarchy brightness display off | omarchy brightness display on no_osd=0 -if [[ ${1:-} == "--no-osd" ]]; then - no_osd=1 - shift -fi +monitor="" + +while (( $# > 0 )); do + case "$1" in + --no-osd) + no_osd=1 + shift + ;; + --monitor) + (( $# >= 2 )) || exit 1 + monitor="$2" + shift 2 + ;; + *) + break + ;; + esac +done # Get the brightness of the passed display -display_brightness() { +backlight_brightness() { brightnessctl -d "$1" -m 2>/dev/null | awk -F, '{ gsub("%", "", $4); print $4; found=1 } END{ exit !found }' } +[[ -n $monitor ]] || monitor="$(omarchy-hyprland-monitor-focused 2>/dev/null || true)" + +monitor_is_internal() { + [[ $monitor =~ ^(eDP|LVDS|DSI)- ]] +} + +use_apple_display() { + omarchy-hyprland-monitor-focused-apple "$monitor" +} + +use_ddc_display() { + [[ -n $monitor ]] && ! monitor_is_internal +} + if (( $# == 0 )); then - if omarchy-hyprland-monitor-focused-apple; then + if use_apple_display; then omarchy-brightness-display-apple exit + elif use_ddc_display; then + omarchy-brightness-display-ddc "$monitor" + exit fi device="$(omarchy-hw-display)" || exit 1 - display_brightness "$device" + backlight_brightness "$device" exit fi @@ -45,18 +76,21 @@ fi exec {lock_fd}>"${XDG_RUNTIME_DIR:-/tmp}/omarchy-brightness-display.lock" flock -n "$lock_fd" || exit 0 -if omarchy-hyprland-monitor-focused-apple; then +if use_apple_display; then if (( no_osd )); then omarchy-brightness-display-apple --no-osd "$step" else omarchy-brightness-display-apple "$step" fi +elif use_ddc_display; then + brightness="$(omarchy-brightness-display-ddc "$monitor" "$step")" || exit 1 + (( no_osd )) || omarchy-osd -i brightness -p "$brightness" else # Current device highlighted device="$(omarchy-hw-display)" || exit 1 # Current brightness percentage - current=$(display_brightness "$device") || exit 1 + current=$(backlight_brightness "$device") || exit 1 # Apply non-uniform step size: 1% steps if at or below 5%, otherwise set an # absolute target percentage to avoid raw backlight rounding causing uneven OSD steps. @@ -84,5 +118,5 @@ else brightnessctl -d "$device" set "$step" >/dev/null # Show the new brightness in OSD - (( no_osd )) || omarchy-osd -i brightness -p "$(display_brightness "$device")" + (( no_osd )) || omarchy-osd -i brightness -p "$(backlight_brightness "$device")" fi diff --git a/bin/omarchy-brightness-display-ddc b/bin/omarchy-brightness-display-ddc new file mode 100755 index 00000000..aab91278 --- /dev/null +++ b/bin/omarchy-brightness-display-ddc @@ -0,0 +1,168 @@ +#!/bin/bash + +# omarchy:summary=Show or adjust DDC/CI display brightness for a Hyprland monitor. +# omarchy:args= [+N%|N%-|N%] +# omarchy:examples=omarchy-brightness-display-ddc DP-1 | omarchy-brightness-display-ddc DP-1 50% + +monitor="${1:-}" +step="${2:-}" + +[[ -n $monitor ]] || exit 1 + +cache_dir="${XDG_RUNTIME_DIR:-/tmp}/omarchy-brightness-display-ddc" +cache_name="${monitor//[^[:alnum:]_.-]/_}" +cache_file="$cache_dir/$cache_name.bus" +unavailable_cache_seconds=60 +range_cache_seconds=10 + +cache_unavailable() { + mkdir -p "$cache_dir" 2>/dev/null || true + printf 'unavailable %s\n' "$(date +%s)" >"$cache_file" 2>/dev/null || true +} + +detect_bus() { + ddcutil --skip-ddc-checks detect --brief 2>/dev/null | awk -v monitor="$monitor" ' + /I2C bus:/ { + bus = $NF + sub(/^.*\/i2c-/, "", bus) + } + + /DRM connector:/ { + connector = $NF + sub(/^card[0-9]+-/, "", connector) + if (connector == monitor && bus != "") { + print bus + exit + } + bus = "" + } + ' +} + +find_bus() { + local bus="" + local cached_value="" + local now=0 + + if [[ -r $cache_file ]]; then + read -r bus cached_value <"$cache_file" || true + fi + + if [[ $bus == "unavailable" ]]; then + now=$(date +%s) + if [[ $cached_value =~ ^[0-9]+$ ]] && (( now - cached_value < unavailable_cache_seconds )); then + return 1 + fi + bus="" + rm -f "$cache_file" + fi + + if [[ -z $bus ]]; then + bus="$(detect_bus)" || return 1 + if [[ ! $bus =~ ^[0-9]+$ ]]; then + cache_unavailable + return 1 + fi + mkdir -p "$cache_dir" 2>/dev/null || true + printf '%s\n' "$bus" >"$cache_file" 2>/dev/null || true + fi + + printf '%s\n' "$bus" +} + +read_vcp() { + local bus="$1" + + ddcutil --bus "$bus" --skip-ddc-checks getvcp 10 --brief 2>/dev/null | awk ' + $1 == "VCP" && toupper($2) == "10" && $3 == "C" && $4 ~ /^[0-9]+$/ && $5 ~ /^[0-9]+$/ && $5 > 0 { + print $4, $5 + found = 1 + exit + } + + END { exit !found } + ' +} + +read_brightness() { + local bus="" + local now=0 + local values="" + + bus="$(find_bus)" || return 1 + values="$(read_vcp "$bus")" || { + rm -f "$cache_file" + return 1 + } + + now=$(date +%s) + mkdir -p "$cache_dir" 2>/dev/null || true + printf '%s %s %s\n' "$bus" "${values##* }" "$now" >"$cache_file" 2>/dev/null || true + printf '%s %s\n' "$bus" "$values" +} + +bus="" +current="" +maximum="" +percent="" +range_cached_at="" + +if [[ -z $step ]]; then + read -r bus current maximum < <(read_brightness) || exit 1 + [[ -n ${bus:-} && -n ${current:-} && -n ${maximum:-} ]] || exit 1 + (( percent = (current * 100 + maximum / 2) / maximum )) + printf '%s\n' "$percent" + exit 0 +fi + +if [[ $step =~ ^([0-9]+)%$ ]]; then + target="${BASH_REMATCH[1]}" + range_cache_fresh=0 + if [[ -r $cache_file ]]; then + read -r bus maximum range_cached_at <"$cache_file" || true + fi + + if [[ $bus =~ ^[0-9]+$ && $maximum =~ ^[0-9]+$ && $range_cached_at =~ ^[0-9]+$ ]] && (( maximum > 0 )); then + now=$(date +%s) + if (( range_cached_at <= now && now - range_cached_at < range_cache_seconds )); then + range_cache_fresh=1 + fi + fi + + if (( ! range_cache_fresh )); then + read -r bus current maximum < <(read_brightness) || exit 1 + fi +elif [[ $step =~ ^\+([0-9]+)%$ ]]; then + read -r bus current maximum < <(read_brightness) || exit 1 + [[ -n ${bus:-} && -n ${current:-} && -n ${maximum:-} ]] || exit 1 + (( percent = (current * 100 + maximum / 2) / maximum )) + amount="${BASH_REMATCH[1]}" + if (( amount == 5 && percent < 5 )); then + (( target = percent + 1 )) + else + (( target = percent + amount )) + fi +elif [[ $step =~ ^([0-9]+)%-$ ]]; then + read -r bus current maximum < <(read_brightness) || exit 1 + [[ -n ${bus:-} && -n ${current:-} && -n ${maximum:-} ]] || exit 1 + (( percent = (current * 100 + maximum / 2) / maximum )) + amount="${BASH_REMATCH[1]}" + if (( amount == 5 && percent <= 5 )); then + (( target = percent - 1 )) + else + (( target = percent - amount )) + fi +else + exit 1 +fi + +(( target < 1 )) && target=1 +(( target > 100 )) && target=100 +(( raw_target = (target * maximum + 50) / 100 )) + +if ! ddcutil --bus "$bus" --skip-ddc-checks --noverify setvcp 10 "$raw_target" >/dev/null 2>&1; then + rm -f "$cache_file" + exit 1 +fi + +printf '%s\n' "$target" diff --git a/bin/omarchy-hyprland-monitor-focused-apple b/bin/omarchy-hyprland-monitor-focused-apple index 99dd55e8..670faa52 100755 --- a/bin/omarchy-hyprland-monitor-focused-apple +++ b/bin/omarchy-hyprland-monitor-focused-apple @@ -1,5 +1,12 @@ #!/bin/bash -# omarchy:summary=Return success if the focused Hyprland monitor is an Apple display. +# omarchy:summary=Return success if the focused or named Hyprland monitor is an Apple display. +# omarchy:args=[monitor] -hyprctl monitors -j | jq -e '.[] | select(.focused == true) | select(.make == "Apple Computer Inc" and (.model | test("StudioDisplay|ProDisplayXDR|Studio XDR")))' >/dev/null +monitor="${1:-}" + +hyprctl monitors -j | jq -e --arg monitor "$monitor" ' + .[] + | select(if $monitor == "" then .focused == true else .name == $monitor end) + | select(.make == "Apple Computer Inc" and (.model | test("StudioDisplay|ProDisplayXDR|Studio XDR"))) +' >/dev/null diff --git a/bin/omarchy-monitor-state b/bin/omarchy-monitor-state index 13452679..f4dcd05c 100755 --- a/bin/omarchy-monitor-state +++ b/bin/omarchy-monitor-state @@ -3,9 +3,10 @@ # omarchy:summary=Print monitor panel state for the shell # omarchy:group=monitor -{ omarchy-brightness-display 2>/dev/null; echo; } | head -n 1 - monitors_json=$(hyprctl monitors all -j) +focused_monitor=$(printf '%s\n' "$monitors_json" | jq -r '[.[] | select(.focused == true)][0].name // ""') + +{ omarchy-brightness-display --monitor "$focused_monitor" 2>/dev/null; echo; } | head -n 1 printf '%s\n' "$monitors_json" | jq -r ' def internal: test("^(eDP|LVDS|DSI)-"); @@ -15,7 +16,7 @@ printf '%s\n' "$monitors_json" | jq -r ' ([.[] | select((.name | internal) and .mirrorOf != "none")][0].mirrorOf // "") ' -omarchy-hyprland-monitor-focused 2>/dev/null || echo +printf '%s\n' "$focused_monitor" omarchy-hyprland-monitor-scaling 2>/dev/null || echo printf '%s\n' "$monitors_json" | jq -c \ diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 2e01c3a1..96d44d7b 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -20,6 +20,7 @@ cups cups-browsed cups-filters cups-pdf +ddcutil docker docker-buildx docker-compose diff --git a/migrations/1785608251.sh b/migrations/1785608251.sh new file mode 100644 index 00000000..5ee563d7 --- /dev/null +++ b/migrations/1785608251.sh @@ -0,0 +1,3 @@ +echo "Install ddcutil for external monitor brightness control" + +omarchy-pkg-add ddcutil diff --git a/shell/plugins/panels/monitor/Panel.qml b/shell/plugins/panels/monitor/Panel.qml index daeb2c8c..1753906e 100644 --- a/shell/plugins/panels/monitor/Panel.qml +++ b/shell/plugins/panels/monitor/Panel.qml @@ -244,7 +244,7 @@ Panel { } root.brightnessSetQueued = false - setBrightnessProc.command = ["bash", "-c", "omarchy-brightness-display --no-osd " + percent + "%"] + setBrightnessProc.command = ["omarchy-brightness-display", "--no-osd", "--monitor", root.focusedMonitor, percent + "%"] setBrightnessProc.running = true } diff --git a/test/shell.d/brightness-display-test.sh b/test/shell.d/brightness-display-test.sh new file mode 100755 index 00000000..3e41c511 --- /dev/null +++ b/test/shell.d/brightness-display-test.sh @@ -0,0 +1,146 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +call_log="$test_tmp/calls" +runtime_dir="$test_tmp/runtime" +mkdir -p "$mock_bin" "$runtime_dir" + +cat >"$mock_bin/omarchy-hyprland-monitor-focused-apple" <<'SH' +#!/bin/bash +exit 1 +SH + +cat >"$mock_bin/omarchy-hyprland-monitor-focused" <<'SH' +#!/bin/bash +printf '%s\n' "${FOCUSED_MONITOR:-eDP-1}" +SH + +cat >"$mock_bin/omarchy-hw-display" <<'SH' +#!/bin/bash +printf 'mock_backlight\n' +SH + +cat >"$mock_bin/brightnessctl" <<'SH' +#!/bin/bash +printf 'brightnessctl %s\n' "$*" >>"$CALL_LOG" +if [[ $* == *" -m"* ]]; then + printf 'mock_backlight,backlight,40,40%%\n' +fi +SH + +cat >"$mock_bin/ddcutil" <<'SH' +#!/bin/bash +printf 'ddcutil %s\n' "$*" >>"$CALL_LOG" + +if [[ $* == *" detect --brief"* ]]; then + cat </dev/null +(( $(grep -c '^ddcutil --skip-ddc-checks detect --brief$' "$call_log") == 1 )) || fail "DDC bus mapping is cached" +pass "DDC bus mapping is cached" + +run_brightness --no-osd --monitor DP-1 25% +grep -F 'ddcutil --bus 7 --skip-ddc-checks --noverify setvcp 10 20' "$call_log" >/dev/null || \ + fail "external percentage is converted to the monitor VCP range" +pass "external percentage is converted to the monitor VCP range" + +get_count=$(grep -c ' getvcp 10 ' "$call_log") +run_brightness --no-osd --monitor DP-1 30% +(( $(grep -c ' getvcp 10 ' "$call_log") == get_count )) || \ + fail "absolute external brightness reuses the cached VCP range" +grep -F 'ddcutil --bus 7 --skip-ddc-checks --noverify setvcp 10 24' "$call_log" >/dev/null || \ + fail "absolute external brightness skips write verification" +pass "absolute external brightness reuses the cached VCP range" + +brightness=$(run_brightness --monitor eDP-1) +[[ $brightness == "40" ]] || fail "internal monitor uses the kernel backlight" "actual: $brightness" +grep -F 'brightnessctl -d mock_backlight -m' "$call_log" >/dev/null || \ + fail "internal monitor queries brightnessctl" +pass "internal monitor uses the kernel backlight" + +brightness=$(FOCUSED_MONITOR=DP-1 run_brightness) +[[ $brightness == "50" ]] || fail "brightness follows the focused external monitor" "actual: $brightness" +pass "brightness follows the focused external monitor" + +detect_count=$(grep -c ' detect --brief' "$call_log") +if DDC_CONNECTOR=DP-1 run_brightness --monitor DP-2 >/dev/null 2>&1; then + fail "unsupported external monitor has no brightness backend" +fi +if DDC_CONNECTOR=DP-1 run_brightness --monitor DP-2 >/dev/null 2>&1; then + fail "cached unsupported external monitor has no brightness backend" +fi +(( $(grep -c ' detect --brief' "$call_log") == detect_count + 1 )) || \ + fail "unsupported external monitor detection is temporarily cached" +pass "unsupported external monitor has no brightness backend" + +rm -f "$runtime_dir/omarchy-brightness-display-ddc/DP-1.bus" +detect_count=$(grep -c ' detect --brief' "$call_log") +if DDC_READ_FAIL=1 run_brightness --monitor DP-1 >/dev/null 2>&1; then + fail "transient DDC read failure is reported" +fi +(( $(grep -c ' detect --brief' "$call_log") == detect_count + 1 )) || \ + fail "transient DDC read failure is not retried immediately" +brightness=$(run_brightness --monitor DP-1) +[[ $brightness == "50" ]] || fail "transient DDC read failure is retried on the next invocation" "actual: $brightness" +(( $(grep -c ' detect --brief' "$call_log") == detect_count + 2 )) || \ + fail "transient DDC read failure does not create a negative cache entry" +pass "transient DDC read failure is retried on the next invocation" + +printf '7 80 0\n' >"$runtime_dir/omarchy-brightness-display-ddc/DP-1.bus" +get_count=$(grep -c ' getvcp 10 ' "$call_log") +DDC_MAXIMUM=100 run_brightness --no-osd --monitor DP-1 50% +(( $(grep -c ' getvcp 10 ' "$call_log") == get_count + 1 )) || \ + fail "expired external brightness range is refreshed" +grep -F 'ddcutil --bus 7 --skip-ddc-checks --noverify setvcp 10 50' "$call_log" >/dev/null || \ + fail "expired external brightness range uses the refreshed maximum" +pass "expired external brightness range is refreshed" + +rm -f "$runtime_dir/omarchy-brightness-display-ddc/DP-1.bus" +DDC_CURRENT=4 DDC_MAXIMUM=100 run_brightness --no-osd --monitor DP-1 +5% +grep -F 'ddcutil --bus 7 --skip-ddc-checks --noverify setvcp 10 5' "$call_log" >/dev/null || \ + fail "external low brightness writes the one-percent target" +pass "external low brightness uses a one-percent step" + +cat >"$mock_bin/hyprctl" <<'SH' +#!/bin/bash +printf '%s\n' '[ + {"name":"DP-1","focused":true,"make":"HPN","model":"OMEN X 25f"}, + {"name":"DP-2","focused":false,"make":"Apple Computer Inc","model":"StudioDisplay"} +]' +SH +chmod +x "$mock_bin/hyprctl" + +PATH="$mock_bin:$PATH" "$ROOT/bin/omarchy-hyprland-monitor-focused-apple" DP-2 || \ + fail "named Apple display is detected independently of focus" +if PATH="$mock_bin:$PATH" "$ROOT/bin/omarchy-hyprland-monitor-focused-apple"; then + fail "focused non-Apple display is not detected as Apple" +fi +pass "named Apple display is detected independently of focus"