Add external monitor brightness over DDC/CI
brightnessctl drives the kernel backlight class, which laptop panels have and this desktop does not -- it reports only keyboard and NIC LEDs. So BrightnessControl removed itself and there was no way to dim the screen from Panama at all. DDC/CI is the channel the buttons on a monitor's bezel drive, and it is the only brightness an external display has. Both sources now render a row each, so a machine gets whichever it actually has, or none. Displays are enumerated from sysfs rather than `ddcutil detect`. The kernel publishes the connector-to-bus mapping as /sys/class/drm/<card>-<connector>/ddc along with whether anything is plugged in, which beats parsing detect's undocumented brief output, yields the connector name spelled exactly as Hyprland spells it, and probes only connectors with a monitor attached -- one bus on this machine rather than fourteen, where each empty bus costs a timeout. No model name is read: Hyprland already knows what every output is called, so the UI joins on the connector instead of keeping a second source of truth that could disagree with the Displays page. Writes are debounced, serial, and read back. Serial because DDC/CI has no arbitration and two ddcutil processes on one bus interleave their exchanges and both return garbage. Read back because a write is not a promise: panels clamp to their own range, ignore values while waking from standby, and drop writes that arrive too fast. Without the read the slider would show what Panama asked for rather than what the monitor did, which is the same class of lie as trusting `hyprctl keyword`. Brightness is deliberately not a stored preference. The monitor remembers it and the bezel buttons change it behind Panama's back, so persisting it would mean restoring a value the panel had moved past. The contract runs against fixtures with ddcutil stubbed and both sysfs roots redirected, so it never touches a real monitor. Its fixture reports a maximum of 200 rather than 100 on purpose -- at 100 the scaling arithmetic is the identity and a helper that ignored the reported maximum would pass everything. Verified it catches that, plus a dropped connection-status filter and an unstripped connector prefix. Not yet confirmed against hardware: this machine cannot open any I2C bus yet. ddcutil's udev rule grants that through uaccess but only to devices created after it was installed, so it needs one udevadm trigger. The helper detects exactly that case and returns the command as its error rather than reporting "no displays". Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
+142
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# panama-brightness enumerates monitors from sysfs and speaks DDC/CI to them.
|
||||
#
|
||||
# The parts worth pinning down are the ones that decide whether a slider appears
|
||||
# at all, and whether it appears attached to the right screen:
|
||||
#
|
||||
# * only connectors with something plugged in are probed, because probing an
|
||||
# empty bus costs a timeout each and there are fourteen of them here;
|
||||
# * a panel that cannot report brightness is omitted rather than shown as a
|
||||
# control that does nothing;
|
||||
# * the connector name matches what Hyprland calls the output, since the UI
|
||||
# joins on it to get the monitor's description;
|
||||
# * no I2C access produces the udev command that fixes it, not "no displays".
|
||||
#
|
||||
# Runs entirely against fixtures. Real monitors are never touched: both the
|
||||
# sysfs root and the device root are redirected, and ddcutil is replaced on PATH.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-brightness"
|
||||
|
||||
fail() {
|
||||
printf 'brightness helper contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
fixture="$(mktemp -d /tmp/panama-brightness.XXXXXX)"
|
||||
trap 'rm -rf "$fixture"' EXIT
|
||||
|
||||
mkdir -p "$fixture/drm" "$fixture/dev" "$fixture/bin" "$fixture/i2c"
|
||||
|
||||
# Two connectors with a monitor, two without. DP-2 answers DDC; DP-3 is
|
||||
# connected but does not implement brightness. HDMI-A-1 and DP-1 are empty and
|
||||
# must never be probed at all.
|
||||
make_connector() {
|
||||
local name="$1" bus="$2" status="$3"
|
||||
mkdir -p "$fixture/drm/$name"
|
||||
printf '%s\n' "$status" >"$fixture/drm/$name/status"
|
||||
mkdir -p "$fixture/i2c/i2c-$bus"
|
||||
ln -sfn "$fixture/i2c/i2c-$bus" "$fixture/drm/$name/ddc"
|
||||
}
|
||||
make_connector card1-DP-1 4 disconnected
|
||||
make_connector card1-DP-2 5 connected
|
||||
make_connector card1-DP-3 6 connected
|
||||
make_connector card1-HDMI-A-1 7 disconnected
|
||||
|
||||
# has_accessible_bus only needs one readable/writable node to exist.
|
||||
touch "$fixture/dev/i2c-5"
|
||||
|
||||
# Stub ddcutil. Records every bus it is asked about so the test can prove the
|
||||
# disconnected ones were skipped. Bus 6 refuses, standing in for a panel without
|
||||
# VCP 0x10.
|
||||
#
|
||||
# Bus 5 reports its brightness out of 200 rather than 100. Most panels do use
|
||||
# 100, which is exactly the problem: with a maximum of 100 the scaling
|
||||
# arithmetic is the identity, so a helper that ignored the reported maximum
|
||||
# entirely would pass every assertion. 200 makes reads and writes that skip the
|
||||
# conversion visibly wrong.
|
||||
cat >"$fixture/bin/ddcutil" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
bus=""
|
||||
args=("$@")
|
||||
for ((i = 0; i < ${#args[@]}; i++)); do
|
||||
[[ "${args[$i]}" == "--bus" ]] && bus="${args[$((i + 1))]}"
|
||||
done
|
||||
printf '%s\n' "$bus" >>"$DDCUTIL_PROBE_LOG"
|
||||
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == "setvcp" ]]; then
|
||||
printf 'set %s %s\n' "$bus" "${args[-1]}" >>"$DDCUTIL_SET_LOG"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
case "$bus" in
|
||||
5) printf 'VCP 10 C 120 200\n'; exit 0 ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
STUB
|
||||
chmod +x "$fixture/bin/ddcutil"
|
||||
|
||||
export DDCUTIL_PROBE_LOG="$fixture/probes.log"
|
||||
export DDCUTIL_SET_LOG="$fixture/sets.log"
|
||||
: >"$DDCUTIL_PROBE_LOG"
|
||||
: >"$DDCUTIL_SET_LOG"
|
||||
|
||||
run_helper() {
|
||||
PATH="$fixture/bin:$PATH" \
|
||||
PANAMA_BRIGHTNESS_DRM_ROOT="$fixture/drm" \
|
||||
PANAMA_BRIGHTNESS_DEV_ROOT="$fixture/dev" \
|
||||
"$helper" "$@"
|
||||
}
|
||||
|
||||
# ── Enumeration ──────────────────────────────────────────────────────────────
|
||||
listing="$(run_helper list)"
|
||||
jq -e . >/dev/null 2>&1 <<<"$listing" || fail "list did not emit JSON: $listing"
|
||||
|
||||
[[ "$(jq -r '.displays | length' <<<"$listing")" == "1" ]] \
|
||||
|| fail "expected exactly one controllable display, got: $listing"
|
||||
|
||||
[[ "$(jq -r '.displays[0].connector' <<<"$listing")" == "DP-2" ]] \
|
||||
|| fail "the connector name must match Hyprland's output name: $listing"
|
||||
|
||||
[[ "$(jq -r '.displays[0].bus' <<<"$listing")" == "5" ]] \
|
||||
|| fail "the display was mapped to the wrong I2C bus: $listing"
|
||||
|
||||
# 120 of a maximum of 200 is 60%.
|
||||
[[ "$(jq -r '.displays[0].value' <<<"$listing")" == "60" ]] \
|
||||
|| fail "brightness was not read as a percent of the reported maximum: $listing"
|
||||
|
||||
[[ "$(jq -r '.error' <<<"$listing")" == "" ]] \
|
||||
|| fail "a successful listing must not carry an error: $listing"
|
||||
|
||||
# A connected panel that cannot report brightness is dropped, not listed.
|
||||
jq -e '.displays | map(.connector) | index("DP-3") == null' >/dev/null <<<"$listing" \
|
||||
|| fail 'a display without VCP 0x10 was listed as controllable'
|
||||
|
||||
# ── Disconnected connectors are never probed ─────────────────────────────────
|
||||
if grep -qxE '4|7' "$DDCUTIL_PROBE_LOG"; then
|
||||
fail "a disconnected connector was probed -- each empty bus costs a timeout: $(tr '\n' ' ' <"$DDCUTIL_PROBE_LOG")"
|
||||
fi
|
||||
|
||||
# ── Writes scale to the reported maximum ─────────────────────────────────────
|
||||
run_helper set 5 40
|
||||
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 80" ]] \
|
||||
|| fail "set did not scale to the display's maximum: $(cat "$DDCUTIL_SET_LOG")"
|
||||
|
||||
run_helper set 5 500
|
||||
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 200" ]] \
|
||||
|| fail "an out-of-range percent was not clamped: $(cat "$DDCUTIL_SET_LOG")"
|
||||
|
||||
# ── No I2C access explains itself ────────────────────────────────────────────
|
||||
rm -f "$fixture/dev"/i2c-*
|
||||
denied="$(run_helper list)"
|
||||
[[ "$(jq -r '.displays | length' <<<"$denied")" == "0" ]] \
|
||||
|| fail "displays were reported without I2C access: $denied"
|
||||
grep -q 'udevadm' <<<"$(jq -r '.error' <<<"$denied")" \
|
||||
|| fail "the no-access error must name the command that fixes it, got: $(jq -r '.error' <<<"$denied")"
|
||||
|
||||
printf 'brightness helper contract: PASS\n'
|
||||
Reference in New Issue
Block a user