#!/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 } # The I2C bus that carries DDC/CI for one connector. # # There are two, and picking the wrong one finds no monitor at all: # # DisplayPort carries DDC/CI over the AUX channel. That adapter shows up as a # child directory of the connector -- /sys/class/drm/card1-DP-2/i2c-9 -- and # is the one ddcutil talks to. # # The connector's `ddc` symlink points at the classic I2C line used for EDID # on HDMI and DVI. On a DisplayPort connector it still exists and still # resolves, but nothing answers on it: this machine's DP-2 has ddc -> i2c-5, # where ddcutil reports "No monitor detected", while i2c-9 answers VCP 0x10 # immediately. # # So prefer the AUX child and fall back to the symlink. Note the readlink: the # entries under /sys/class/drm are symlinks, and `find` does not follow the path # it is given, so searching the unresolved path silently finds nothing. bus_for_connector() { local path="$1" real aux ddc real="$(readlink -f "$path")" aux="$(find "$real" -maxdepth 1 -name 'i2c-*' -printf '%f' -quit 2>/dev/null)" if [[ -n "$aux" ]]; then printf '%s' "${aux#i2c-}" return 0 fi ddc="$(readlink -f "$path/ddc" 2>/dev/null)" || return 1 [[ -n "$ddc" ]] || return 1 ddc="$(basename "$ddc")" printf '%s' "${ddc#i2c-}" } 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="$(bus_for_connector "$path")" [[ "$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