With I2C access granted, the helper still found nothing: every connected monitor was probed on the wrong bus. A connector has two. The `ddc` symlink points at the classic I2C line that carries EDID on HDMI and DVI. DisplayPort carries DDC/CI over the AUX channel instead, which appears as a child directory of the connector. Both exist on a DP connector and both resolve, so the wrong choice looks entirely reasonable and simply finds no monitor: this machine's DP-2 has ddc -> i2c-5, where ddcutil reports "No monitor detected", while its AUX child i2c-9 answers VCP 0x10 immediately. This is the guess the previous commit said was unverified, and it was wrong in the way that mattered. Enumerating from sysfs is still right -- it gives the connector name Hyprland uses and skips empty connectors -- but it has to prefer the AUX child and fall back to the symlink. The fixture now mirrors sysfs properly: /sys/class/drm/<connector> is a SYMLINK to the real device directory, and `find` does not follow the path it is given. A fixture built from plain directories passes whether or not the code resolves the symlink first, which is a test that agrees with itself rather than with the kernel. Verified on hardware: the Kuycon P20 reports 100%, accepts 70 and returns to 100, and the media keys move it through codex's OSD path. Both bus-selection mistakes are now caught by the contract. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
158 lines
6.5 KiB
Bash
Executable File
158 lines
6.5 KiB
Bash
Executable File
#!/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 <bus> -> integer percent
|
|
# panama-brightness set <bus> <pct> -> 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/<card>-<connector>/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 <current> <max>"; 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 <bus>|set <bus> <percent>]\n' >&2; exit 2 ;;
|
|
esac
|