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"