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:
@@ -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
|
// Two exist and they are not interchangeable:
|
||||||
// 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
|
// The kernel backlight class, driven by brightnessctl. Laptop panels have it;
|
||||||
// at startup — backlight devices do not appear and disappear.
|
// 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 QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import qs.widgets
|
import qs.widgets
|
||||||
import qs.config
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property bool available: false
|
property bool backlightAvailable: false
|
||||||
property real value: 0
|
property real backlightValue: 0
|
||||||
|
|
||||||
visible: root.available
|
readonly property int rowCount: (root.backlightAvailable ? 1 : 0) + Brightness.displays.length
|
||||||
implicitHeight: root.available ? 32 : 0
|
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
|
// `-m` is the machine-readable form: name,class,current,percent,max
|
||||||
Process {
|
Process {
|
||||||
@@ -34,36 +54,105 @@ Item {
|
|||||||
const fields = line.split(",");
|
const fields = line.split(",");
|
||||||
if (fields.length < 5 || fields[1] !== "backlight")
|
if (fields.length < 5 || fields[1] !== "backlight")
|
||||||
continue;
|
continue;
|
||||||
root.available = true;
|
root.backlightAvailable = true;
|
||||||
root.value = parseInt(fields[3]) / 100;
|
root.backlightValue = parseInt(fields[3]) / 100;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function apply(v: real): void {
|
function applyBacklight(v: real): void {
|
||||||
root.value = v;
|
root.backlightValue = v;
|
||||||
// Never go fully dark: a 0% backlight looks like a broken shell.
|
// 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)) + "%"]);
|
Quickshell.execDetached(["brightnessctl", "-c", "backlight", "-q", "set", Math.max(1, Math.round(v * 100)) + "%"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValueSlider draws its own leading icon, but symbolic icons need
|
Column {
|
||||||
// recolouring to be visible — see ThemedIcon.
|
id: rows
|
||||||
ThemedIcon {
|
|
||||||
id: glyph
|
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: 6
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
spacing: 4
|
||||||
size: 17
|
|
||||||
icon: "display-brightness-symbolic"
|
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 {
|
component BrightnessRow: Item {
|
||||||
anchors.left: glyph.right
|
id: row
|
||||||
anchors.leftMargin: 12
|
|
||||||
anchors.right: parent.right
|
property string label: ""
|
||||||
anchors.rightMargin: 32
|
property real value: 0
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
signal moved(real value)
|
||||||
value: root.value
|
|
||||||
onMoved: v => root.apply(v)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Displays.
|
// Displays.
|
||||||
//
|
//
|
||||||
// Resolution, refresh rate, scale, and rotation, plus the gaming display
|
// Resolution, refresh rate, scale, and rotation, plus panel brightness and the
|
||||||
// policy that was already here.
|
// gaming display policy that was already here.
|
||||||
//
|
//
|
||||||
// Every geometry change goes through an apply-then-confirm countdown. This is
|
// 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,
|
// 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 : "";
|
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 {
|
Connections {
|
||||||
target: Displays
|
target: Displays
|
||||||
function onMonitorsChanged(): void { root.syncSelectedOutput(); }
|
function onMonitorsChanged(): void { root.syncSelectedOutput(); }
|
||||||
@@ -200,6 +207,45 @@ SettingsPage {
|
|||||||
SliderRow { setting: "nightLightTemperature"; divider: false }
|
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 {
|
SettingsCard {
|
||||||
title: "Gaming display policy"
|
title: "Gaming display policy"
|
||||||
subtitle: "Applied immediately and restored when Panama starts."
|
subtitle: "Applied immediately and restored when Panama starts."
|
||||||
|
|||||||
+125
@@ -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 <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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <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
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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