#!/usr/bin/env bash # External monitor brightness over DDC/CI. # # A desktop with no backlight class device has no brightness control at all -- # brightnessctl only sees keyboard and NIC LEDs. The panel itself still has a # brightness setting, reachable over the monitor's DDC/CI channel (VCP feature # 0x10), which is what the buttons on the bezel drive. # # Usage: # panama-brightness list -> {"displays":[...],"error":""} # panama-brightness get -> integer percent # panama-brightness set -> applies, prints nothing # # Displays are enumerated from sysfs rather than from `ddcutil detect`. The # kernel publishes the connector-to-I2C-bus mapping directly, as # /sys/class/drm/-/ddc, along with whether anything is plugged # in. That is better than parsing detect output in three ways: the format is # stable where detect's brief output is undocumented, the connector name comes # out exactly as Hyprland and the Displays page already spell it (DP-2), and # only connectors with a monitor attached get probed -- one bus on this machine # instead of fourteen, which is the difference between a fast scan and a slow # one, since each probe of an empty bus waits for a timeout. # # No model name is reported. Hyprland already knows the human-readable # description of every output, so the UI joins on the connector name rather than # having two sources of truth for what a monitor is called. set -uo pipefail readonly VCP_BRIGHTNESS=0x10 # Test seams. The contract needs to exercise enumeration and parsing on a # machine whose real monitors it must not touch, so both roots this script # reads are overridable. Nothing sets them in normal use. readonly DRM_ROOT="${PANAMA_BRIGHTNESS_DRM_ROOT:-/sys/class/drm}" readonly DEV_ROOT="${PANAMA_BRIGHTNESS_DEV_ROOT:-/dev}" emit_error() { printf '{"displays":[],"error":%s}\n' "$(jq -Rn --arg e "$1" '$e')" exit 0 } command -v ddcutil >/dev/null 2>&1 || emit_error 'ddcutil is not installed' # Reading a VCP value needs read/write access to the monitor's I2C bus. The # udev rule ddcutil ships grants that to the seat user through uaccess, but only # to devices created after the rule was installed -- so a machine that installed # ddcutil without rebooting has the rule in place and no access to show for it. # That is by far the most likely reason for an empty list, and it is fixable in # one command, so say so rather than reporting "no displays". has_accessible_bus() { local dev for dev in "$DEV_ROOT"/i2c-*; do [[ -r "$dev" && -w "$dev" ]] && return 0 done return 1 } cmd_list() { has_accessible_bus || emit_error 'no I2C bus is accessible. ddcutil ships a udev rule that grants this, but only to devices created after it was installed. Run: sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm' local rows=() connector bus value path for path in "$DRM_ROOT"/card*-*; do [[ -e "$path/ddc" ]] || continue [[ "$(cat "$path/status" 2>/dev/null)" == "connected" ]] || continue # card1-DP-2 -> DP-2, the name Hyprland uses. connector="$(basename "$path")" connector="${connector#card*-}" bus="$(basename "$(readlink -f "$path/ddc")")" bus="${bus#i2c-}" [[ "$bus" =~ ^[0-9]+$ ]] || continue # A monitor that does not implement 0x10 is not an error; it simply # cannot be controlled, and is left out rather than shown as a slider # that does nothing. value="$(cmd_get "$bus")" || continue [[ -n "$value" ]] || continue rows+=("$(jq -cn \ --argjson bus "$bus" \ --arg connector "$connector" \ --argjson value "$value" \ '{bus: $bus, connector: $connector, value: $value}')") done if [[ ${#rows[@]} -eq 0 ]]; then emit_error 'no connected monitor reports DDC/CI brightness. Some panels implement it only when "DDC/CI" is enabled in their on-screen menu.' fi printf '{"displays":[%s],"error":""}\n' "$(IFS=,; printf '%s' "${rows[*]}")" } # Prints the current brightness as a whole percent, or nothing when the display # cannot report it. `getvcp --brief` is documented as machine readable and # answers "VCP 10 C "; the max is almost always 100 but is not # guaranteed to be, so it is read rather than assumed. cmd_get() { local bus="$1" out current max out="$(timeout 10 ddcutil --bus "$bus" getvcp "$VCP_BRIGHTNESS" --brief 2>/dev/null)" || return 1 read -r _ _ _ current max <<<"$out" [[ "$current" =~ ^[0-9]+$ && "$max" =~ ^[0-9]+$ && "$max" -gt 0 ]] || return 1 printf '%s' "$(( current * 100 / max ))" } cmd_set() { local bus="$1" percent="$2" max out [[ "$percent" =~ ^[0-9]+$ ]] || return 1 (( percent > 100 )) && percent=100 out="$(timeout 10 ddcutil --bus "$bus" getvcp "$VCP_BRIGHTNESS" --brief 2>/dev/null)" || return 1 read -r _ _ _ _ max <<<"$out" [[ "$max" =~ ^[0-9]+$ && "$max" -gt 0 ]] || max=100 timeout 10 ddcutil --bus "$bus" setvcp "$VCP_BRIGHTNESS" "$(( percent * max / 100 ))" >/dev/null 2>&1 } case "${1:-list}" in list) cmd_list ;; get) cmd_get "${2:?bus required}" ;; set) cmd_set "${2:?bus required}" "${3:?percent required}" ;; *) printf 'usage: panama-brightness [list|get |set ]\n' >&2; exit 2 ;; esac