diff --git a/config/dot/quickshell/modules/quicksettings/BrightnessControl.qml b/config/dot/quickshell/modules/quicksettings/BrightnessControl.qml index 29cc4bf..8dc983a 100644 --- a/config/dot/quickshell/modules/quicksettings/BrightnessControl.qml +++ b/config/dot/quickshell/modules/quicksettings/BrightnessControl.qml @@ -1,24 +1,44 @@ -// Backlight slider, via brightnessctl. +// Brightness, from whichever source this machine actually has. // -// This machine drives an external DisplayPort monitor and has no backlight -// class device at all (brightnessctl only reports keyboard/NIC LEDs), so the -// row removes itself rather than sitting there as a dead control. Probed once -// at startup — backlight devices do not appear and disappear. +// Two exist and they are not interchangeable: +// +// The kernel backlight class, driven by brightnessctl. Laptop panels have it; +// this desktop does not -- brightnessctl reports only keyboard and NIC LEDs. +// +// DDC/CI, the channel the buttons on a monitor's bezel drive. That is the +// only brightness an external display has, and it is per-monitor. +// +// A machine may have neither, either, or both, so this renders a row per source +// found and removes itself entirely when there are none, rather than sitting +// there as a dead control. +// +// Connector labels appear only when there is more than one row. A single +// slider needs no explanation of which screen it dims. import QtQuick import Quickshell import Quickshell.Io import qs.widgets import qs.config +import qs.services Item { id: root - property bool available: false - property real value: 0 + property bool backlightAvailable: false + property real backlightValue: 0 - visible: root.available - implicitHeight: root.available ? 32 : 0 + readonly property int rowCount: (root.backlightAvailable ? 1 : 0) + Brightness.displays.length + readonly property bool labelled: root.rowCount > 1 + + visible: root.rowCount > 0 + implicitHeight: rows.implicitHeight + + // Probing I2C takes on the order of a second, so it waits until the panel + // is actually on screen rather than running at shell startup. Monitors do + // not come and go, so once is enough. + onVisibleChanged: if (visible && !Brightness.scanned) Brightness.refresh() + Component.onCompleted: if (root.visible && !Brightness.scanned) Brightness.refresh() // `-m` is the machine-readable form: name,class,current,percent,max Process { @@ -34,36 +54,105 @@ Item { const fields = line.split(","); if (fields.length < 5 || fields[1] !== "backlight") continue; - root.available = true; - root.value = parseInt(fields[3]) / 100; + root.backlightAvailable = true; + root.backlightValue = parseInt(fields[3]) / 100; return; } } - function apply(v: real): void { - root.value = v; + function applyBacklight(v: real): void { + root.backlightValue = v; // Never go fully dark: a 0% backlight looks like a broken shell. Quickshell.execDetached(["brightnessctl", "-c", "backlight", "-q", "set", Math.max(1, Math.round(v * 100)) + "%"]); } - // ValueSlider draws its own leading icon, but symbolic icons need - // recolouring to be visible — see ThemedIcon. - ThemedIcon { - id: glyph + Column { + id: rows anchors.left: parent.left - anchors.leftMargin: 6 - anchors.verticalCenter: parent.verticalCenter - size: 17 - icon: "display-brightness-symbolic" + anchors.right: parent.right + spacing: 4 + + BrightnessRow { + width: rows.width + visible: root.backlightAvailable + label: "Built-in" + value: root.backlightValue + onMoved: v => root.applyBacklight(v) + } + + Repeater { + model: Brightness.displays + + BrightnessRow { + required property var modelData + width: rows.width + // Hyprland already knows what each output is called, so the + // name comes from there rather than from a second source that + // could disagree with the Displays page. The connector is the + // fallback, so a display is never an unlabelled slider. + label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector + value: modelData.value / 100 + onMoved: v => Brightness.set(modelData.bus, Math.round(v * 100)) + } + } } - ValueSlider { - anchors.left: glyph.right - anchors.leftMargin: 12 - anchors.right: parent.right - anchors.rightMargin: 32 - anchors.verticalCenter: parent.verticalCenter - value: root.value - onMoved: v => root.apply(v) + component BrightnessRow: Item { + id: row + + property string label: "" + property real value: 0 + signal moved(real value) + + implicitHeight: caption.height + control.height + + Text { + id: caption + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 6 + anchors.rightMargin: 6 + anchors.top: parent.top + visible: root.labelled + height: visible ? implicitHeight + 2 : 0 + text: row.label + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + } + + // The slider and its glyph share one strip so the two stay aligned + // whether or not a caption sits above them. Anchoring the glyph to + // both a caption and a centre line instead would conflict, and an + // anchor set to undefined is not released. + Item { + id: control + anchors.left: parent.left + anchors.right: parent.right + anchors.top: caption.bottom + height: 32 + + // ValueSlider draws its own leading icon, but symbolic icons need + // recolouring to be visible — see ThemedIcon. + ThemedIcon { + id: glyph + anchors.left: parent.left + anchors.leftMargin: 6 + anchors.verticalCenter: parent.verticalCenter + size: 17 + icon: "display-brightness-symbolic" + } + + ValueSlider { + anchors.left: glyph.right + anchors.leftMargin: 12 + anchors.right: parent.right + anchors.rightMargin: 32 + anchors.verticalCenter: parent.verticalCenter + value: row.value + onMoved: v => row.moved(v) + } + } } } diff --git a/config/dot/quickshell/modules/settings/DisplaysPage.qml b/config/dot/quickshell/modules/settings/DisplaysPage.qml index 729aaea..638eb7b 100644 --- a/config/dot/quickshell/modules/settings/DisplaysPage.qml +++ b/config/dot/quickshell/modules/settings/DisplaysPage.qml @@ -1,7 +1,7 @@ // Displays. // -// Resolution, refresh rate, scale, and rotation, plus the gaming display -// policy that was already here. +// Resolution, refresh rate, scale, and rotation, plus panel brightness and the +// gaming display policy that was already here. // // Every geometry change goes through an apply-then-confirm countdown. This is // the one page where a wrong value can leave the screen unreadable or blank, @@ -32,7 +32,14 @@ SettingsPage { root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : ""; } - Component.onCompleted: root.syncSelectedOutput() + // Probing I2C for DDC-capable monitors takes on the order of a second, so + // it runs when this page is opened rather than at shell startup. Monitors + // do not appear while you are looking at a settings page, so once is enough. + Component.onCompleted: { + root.syncSelectedOutput(); + if (!Brightness.scanned) + Brightness.refresh(); + } Connections { target: Displays function onMonitorsChanged(): void { root.syncSelectedOutput(); } @@ -200,6 +207,45 @@ SettingsPage { SliderRow { setting: "nightLightTemperature"; divider: false } } + // Panel brightness, over DDC/CI. + // + // This is hardware state rather than a stored preference: the monitor + // remembers it, the bezel buttons change it behind Panama's back, and + // writing it into settings.json would mean restoring a value the panel had + // already moved on from. So there is no schema key here and no SliderRow -- + // the rows read and write the display directly. + SettingsCard { + visible: Brightness.available || Brightness.lastError !== "" + title: "Brightness" + subtitle: Brightness.available + ? "Sent to the monitor over DDC/CI, the same channel its buttons use." + : Brightness.lastError + + Repeater { + model: Brightness.displays + + SettingRow { + id: brightnessRow + required property var modelData + required property int index + + label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector + detail: modelData.connector ? modelData.connector + " · " + modelData.value + "%" + : modelData.value + "%" + divider: brightnessRow.index < Brightness.displays.length - 1 + controlWidth: 190 + + ValueSlider { + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + width: parent.width + value: brightnessRow.modelData.value / 100 + onMoved: v => Brightness.set(brightnessRow.modelData.bus, Math.round(v * 100)) + } + } + } + } + SettingsCard { title: "Gaming display policy" subtitle: "Applied immediately and restored when Panama starts." diff --git a/config/dot/quickshell/scripts/panama-brightness b/config/dot/quickshell/scripts/panama-brightness new file mode 100755 index 0000000..5e3af27 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-brightness @@ -0,0 +1,125 @@ +#!/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 diff --git a/config/dot/quickshell/services/Brightness.qml b/config/dot/quickshell/services/Brightness.qml new file mode 100644 index 0000000..c6db15b --- /dev/null +++ b/config/dot/quickshell/services/Brightness.qml @@ -0,0 +1,163 @@ +pragma Singleton + +// Panel brightness for external monitors, over DDC/CI. +// +// brightnessctl covers laptop panels through the kernel's backlight class. A +// desktop driving a DisplayPort monitor has no such device, so it has no +// brightness control at all -- the only way to dim the screen is the buttons on +// the bezel. DDC/CI is the channel those buttons drive, and monitors expose it +// over the same I2C lines that carry EDID. +// +// Two things shape everything here: +// +// Detection is slow. Probing every I2C bus takes on the order of a second, +// which is far too slow to sit in front of a settings page opening. It runs +// once, on demand, and afterwards each display is addressed by its bus number +// directly. +// +// Writes are slow AND rate-limited by the monitor's firmware. A slider drag +// emits values continuously; sending each one produces a queue the panel +// works through seconds after the user let go, and some monitors drop or +// garble writes that arrive too fast. So `value` updates immediately for the +// UI and the hardware write is debounced, with only the latest value sent. +// +// Displays are keyed by DRM connector name (DP-2) so they line up with what +// Hyprland, the Displays page, and the monitor list already call them. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-brightness" + + // [{ bus, connector, model, value }] where value is 0..100. + property var displays: [] + property bool scanning: false + + // Empty when everything is fine. Carries the helper's explanation + // otherwise -- most usefully the udev command that grants I2C access, + // which is the difference between "brightness is unavailable" and + // "brightness is one command away". + property string lastError: "" + + readonly property bool available: root.displays.length > 0 + + // True once a scan has completed, however it went. Lets the UI tell "not + // looked yet" apart from "looked and found nothing", which otherwise render + // identically and leave a permanently empty panel with no explanation. + property bool scanned: false + + // Pending writes, keyed by bus. A monitor being dragged accumulates exactly + // one entry no matter how many values the slider emits. + property var pending: ({}) + + function refresh(): void { + if (root.scanning) + return; + root.scanning = true; + scan.running = true; + } + + function displayFor(connector: string): var { + return root.displays.find(display => display.connector === connector) ?? null; + } + + // Sets brightness for one display. The stored value moves at once so the + // slider tracks the pointer; the hardware follows when the drag settles. + function set(bus: int, percent: int): void { + const clamped = Math.max(0, Math.min(100, Math.round(percent))); + + root.displays = root.displays.map(display => + display.bus === bus ? Object.assign({}, display, { value: clamped }) : display); + + const next = Object.assign({}, root.pending); + next[String(bus)] = clamped; + root.pending = next; + writeDebounce.restart(); + } + + Process { + id: scan + command: [root.helperPath, "list"] + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + root.displays = Array.isArray(parsed.displays) ? parsed.displays : []; + root.lastError = String(parsed.error ?? ""); + } catch (error) { + root.displays = []; + root.lastError = "Could not read the brightness helper's output."; + console.warn("Brightness: could not parse helper output:", error); + } + root.scanning = false; + root.scanned = true; + } + } + } + + // Long enough that a drag produces one write rather than dozens, short + // enough that a single click still feels immediate. + Timer { + id: writeDebounce + interval: 120 + onTriggered: root.pump() + } + + // Writes run one at a time, and each is read back. + // + // Serial because DDC/CI is a bus protocol with no arbitration: two ddcutil + // processes talking to the same monitor interleave their exchanges and both + // can come back with 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 quickly. Without the read the slider shows + // what Panama asked for rather than what the monitor did, which is the same + // class of lie as trusting `hyprctl keyword` to have applied something. + property int writingBus: -1 + + function pump(): void { + if (writer.running || reader.running) + return; + + for (const bus in root.pending) { + const value = root.pending[bus]; + const remaining = Object.assign({}, root.pending); + delete remaining[bus]; + root.pending = remaining; + + root.writingBus = parseInt(bus); + writer.command = [root.helperPath, "set", bus, String(value)]; + writer.running = true; + return; + } + } + + Process { + id: writer + onExited: { + reader.command = [root.helperPath, "get", String(root.writingBus)]; + reader.running = true; + } + } + + Process { + id: reader + stdout: StdioCollector { + onStreamFinished: { + const actual = parseInt(this.text.trim()); + if (!isNaN(actual)) { + root.displays = root.displays.map(display => + display.bus === root.writingBus + ? Object.assign({}, display, { value: actual }) + : display); + } + root.writingBus = -1; + // Anything queued while this write was in flight goes now. + root.pump(); + } + } + } +} diff --git a/tests/quickshell/brightness-helper-contract.sh b/tests/quickshell/brightness-helper-contract.sh new file mode 100755 index 0000000..085ae65 --- /dev/null +++ b/tests/quickshell/brightness-helper-contract.sh @@ -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'