Fast External Monitor Brightness Keys on Fedora + Hyprland (DDC/CI → ddcci_backlight → brightnessctl)
Context / Environment
This post documents a battle-tested setup for controlling external monitor brightness on:
- OS: Fedora Linux (modern Fedora, systemd-based)
- Compositor: Hyprland (Wayland)
- Shell: zsh (not critical, but relevant for globbing gotchas)
- Hardware: external Dell monitor(s) over DisplayPort
- Target connector:
DP-2(as shown by DRM connector naming) - Goal: bind
XF86MonBrightnessUp/Downto a fast brightness adjustment path
If you have a laptop panel, you probably already have a backlight device under /sys/class/backlight. For external monitors, it’s more complicated: you often need DDC/CI.
The Problem
External monitors that support DDC/CI can be controlled via ddcutil:
ddcutil setvcp 10 50 # set brightness to 50
but this can feel slow for repeated key presses, and it can be unreliable at boot (I2C/DRM not ready, ddcci_backlight probe races, etc.).
I wanted:
- Low latency brightness key presses
- Robust boot-time behavior (it should “just work” every boot)
- Minimal privilege exposure (no broad
videogroup / udev write permissions)
Key Idea
Use DDC/CI only to expose a kernel backlight interface, then adjust brightness using:
-
brightnessctl→ sysfs backlight write → fast
Pipeline
Brightness keys → sudo -n wrapper → brightnessctl → /sys/class/backlight/ddcci*
The critical ingredient is ddcci-driver-linux (and its ddcci_backlight module), which can create a backlight device like:
/sys/class/backlight/ddcci8
Once you have that, brightness control becomes quick and consistent.
Why Not Only sysfs / DRM?
On some machines, you can derive the I2C bus from sysfs paths under:
/sys/class/drm/cardX-DP-2/...
…but on my system, the connector directory only exposed standard connector attributes (EDID, modes, status, etc.) and did not expose a ddc/i2c-dev path. So fully sysfs-based discovery of the I2C bus was not possible.
That’s why the implementation uses:
- ddcutil detect as a fallback mapping from
DP-2→/dev/i2c-N
…and then caches the bus so future boots can often skip ddcutil entirely.
Final Components
Packages / Modules
You need:
brightnessctlddcutil-
ddcci-driver-linux(Fedora often viaakmod-ddcci-driver-linux) -
kernel modules:
ddcciddcci_backlight
Files We Keep (Final)
-
Autofix script (root)
/usr/local/sbin/ddcci-dp2-autofix -
systemd service (root)
/etc/systemd/system/ddcci-dp2-autofix.service -
Brightness wrapper (root)
/usr/local/sbin/ddcci8-brightness -
sudoers entry (root)
/etc/sudoers.d/ddcci8-brightness
Plus two generated artifacts:
- Runtime symlink (tmpfs):
/run/backlight-ddp2 -> /sys/class/backlight/ddcci<bus> - Persistent bus cache:
/var/lib/ddcci-dp2/bus(e.g. contains8)
Design Goals of the Autofix Script
The script is designed to be boringly reliable:
Fast path (common case)
If backlight already exists:
- use cached bus to pick the correct device if available
- create
/run/backlight-ddp2 - write/update the cache
- exit immediately
Repair path (when ddcci backlight is missing)
- Prefer cached bus as soon as
/sys/bus/i2c/devices/i2c-<bus>/new_devicebecomes writable - Only if cache isn’t usable, fall back to
ddcutil detectto mapDP-2 → i2c-N - Ensure an I2C client exists at address
0x37(ddcci 0x37) - Reload
ddcci_backlightuntil/sys/class/backlight/ddcci<bus>appears - Create
/run/backlight-ddp2and update the cache
Noise control
Some ddcutil setups emit lots of messages into the journal. The script uses:
ddcutil detect --syslog never
to reduce journald spam.
1) systemd Service
Create:
/etc/systemd/system/ddcci-dp2-autofix.service
[Unit]
Description=DDCCI DP-2 backlight autofix (robust, ddcutil-first)
After=graphical.target systemd-modules-load.service
Wants=graphical.target
[Service]
Type=simple
Environment=HOME=/root
Environment=XDG_CACHE_HOME=/root/.cache
Nice=10
IOSchedulingClass=idle
ExecStart=/usr/local/sbin/ddcci-dp2-autofix
[Install]
WantedBy=graphical.target
Enable it:
sudo systemctl daemon-reload
sudo systemctl enable --now ddcci-dp2-autofix.service
This service is expected to exit successfully and show as “deactivated”.
2) The Autofix Script (Final)
Create:
/usr/local/sbin/ddcci-dp2-autofix
#!/usr/bin/env bash
set -euo pipefail
log() { echo "[ddcci] $*" >&2; } # never pollute stdout
export HOME="${HOME:-/root}"
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-/root/.cache}"
need() { command -v "$1" >/dev/null 2>&1; }
for c in modprobe awk cat sleep ls head mkdir basename; do
need "$c" || { log "missing command: $c (exit 0)"; exit 0; }
done
# -------- bus cache (persistent) --------
BUS_CACHE_DIR=/var/lib/ddcci-dp2
BUS_CACHE_FILE=$BUS_CACHE_DIR/bus
mkdir -p "$BUS_CACHE_DIR"
CACHED_BUS=""
if [[ -r "$BUS_CACHE_FILE" ]]; then
c="$(cat "$BUS_CACHE_FILE" 2>/dev/null || true)"
[[ "$c" =~ ^[0-9]+$ ]] && CACHED_BUS="$c"
fi
# ---------------------------------------
log "start"
# 1) If cached bus backlight exists, use it (prevents choosing wrong device)
if [[ -n "$CACHED_BUS" && -d "/sys/class/backlight/ddcci${CACHED_BUS}" ]]; then
ln -sfn "/sys/class/backlight/ddcci${CACHED_BUS}" /run/backlight-ddp2
log "backlight already present (cached): ddcci${CACHED_BUS}; linked /run/backlight-ddp2"
exit 0
fi
# 2) If any ddcci backlight exists, link it and update cache from name
existing_bl="$(ls -d /sys/class/backlight/ddcci[0-9]* 2>/dev/null | head -n 1 || true)"
if [[ -n "$existing_bl" && -d "$existing_bl" ]]; then
ln -sfn "$existing_bl" /run/backlight-ddp2
bn="$(basename "$existing_bl")"
if [[ "$bn" =~ ^ddcci([0-9]+)$ ]]; then
echo "${BASH_REMATCH[1]}" > "$BUS_CACHE_FILE" 2>/dev/null || true
log "backlight already present: $bn; linked /run/backlight-ddp2; cached bus=${BASH_REMATCH[1]}"
else
log "backlight already present: $bn; linked /run/backlight-ddp2"
fi
exit 0
fi
modprobe -q ddcci || true
modprobe -q ddcci_backlight || true
deadline=120
start=$SECONDS
DP_SUFFIX="DP-2"
BUS=""
resolve_bus_ddcutil() {
local b
b="$(
{ ddcutil detect --syslog never 2>/dev/null || true; } | awk -v suf="$DP_SUFFIX" '
/^Display [0-9]+/ { bus="" }
/I2C bus:/ { if (match($0,/\/dev\/i2c-([0-9]+)/,m)) bus=m[1] }
/DRM_connector:|DRM connector:/ {
for (i=1; i<=NF; i++) {
if ($i ~ (suf "$")) { if (bus != "") { print bus; exit } }
}
}
'
)"
[[ "$b" =~ ^[0-9]+$ ]] || return 1
echo "$b"
}
ensure_ddcci_client_and_backlight() {
local bus="$1"
local bl="/sys/class/backlight/ddcci${bus}"
if [[ -d "$bl" ]]; then
ln -sfn "$bl" /run/backlight-ddp2
echo "$bus" > "$BUS_CACHE_FILE" 2>/dev/null || true
log "ok: $bl exists; linked /run/backlight-ddp2; cached bus=$bus"
return 0
fi
local newdev="/sys/bus/i2c/devices/i2c-${bus}/new_device"
local deldev="/sys/bus/i2c/devices/i2c-${bus}/delete_device"
local namefile="/sys/bus/i2c/devices/${bus}-0037/name"
if [[ ! -w "$newdev" ]]; then
log "new_device not writable yet: $newdev"
return 2
fi
local current_name=""
current_name="$(cat "$namefile" 2>/dev/null || true)"
if [[ ! -f "$namefile" || "$current_name" != "ddcci" ]]; then
log "(re)create ddcci client at 0x37 on i2c-${bus}"
echo 0x37 > "$deldev" 2>/dev/null || true
echo "ddcci 0x37" > "$newdev" 2>/dev/null || true
fi
log "reload ddcci_backlight"
modprobe -rq ddcci_backlight || true
modprobe -q ddcci_backlight || true
for _ in 1 2 3 4 5; do
if [[ -d "$bl" ]]; then
ln -sfn "$bl" /run/backlight-ddp2
echo "$bus" > "$BUS_CACHE_FILE" 2>/dev/null || true
log "ok after reload: $bl exists; linked /run/backlight-ddp2; cached bus=$bus"
return 0
fi
sleep 1
done
return 1
}
last_detect=0
no_progress=0
while (( SECONDS - start < deadline )); do
# Prefer cached bus as soon as new_device becomes writable
if [[ -z "$BUS" && -n "$CACHED_BUS" && -w "/sys/bus/i2c/devices/i2c-${CACHED_BUS}/new_device" ]]; then
BUS="$CACHED_BUS"
log "use cached bus: i2c-${BUS}"
fi
# Otherwise, fallback to ddcutil detect (rate-limited)
if [[ -z "$BUS" ]]; then
if (( SECONDS - last_detect >= 3 )); then
if b="$(resolve_bus_ddcutil 2>/dev/null || true)"; then
BUS="$b"
log "DP-2 bus cached: i2c-${BUS} (source=ddcutil)"
else
log "waiting for DP-2 bus..."
fi
last_detect=$SECONDS
fi
[[ -z "$BUS" ]] && sleep 1 && continue
fi
if ensure_ddcci_client_and_backlight "$BUS"; then
exit 0
fi
no_progress=$((no_progress + 1))
if (( no_progress >= 8 )); then
log "no progress; re-resolve bus"
BUS=""
no_progress=0
fi
sleep 1
done
log "timeout without backlight"
exit 0
Install permissions:
sudo chmod 0755 /usr/local/sbin/ddcci-dp2-autofix
3) Security Model: Wrapper + Minimal sudoers
Wrapper: /usr/local/sbin/ddcci8-brightness
The wrapper allows only up/down/set and binds to the current ddcci device via /run/backlight-ddp2, so you don’t hardcode ddcci8 forever.
#!/usr/bin/env bash
set -euo pipefail
die() { echo "usage: $0 {up|down|set <0-100%>}" >&2; exit 2; }
step="${STEP_PERCENT:-10}"
if [[ -L /run/backlight-ddp2 ]]; then
dev="$(basename "$(readlink -f /run/backlight-ddp2)")"
else
dev="ddcci8" # fallback
fi
case "${1:-}" in
up) exec /usr/bin/brightnessctl -q -d "$dev" set +"$step"% ;;
down) exec /usr/bin/brightnessctl -q -d "$dev" set "$step"%- ;;
set)
v="${2:-}"
[[ "$v" =~ ^([0-9]{1,3})%$ ]] || die
n="${BASH_REMATCH[1]}"
(( n>=0 && n<=100 )) || die
exec /usr/bin/brightnessctl -q -d "$dev" set "$n"% ;;
*) die ;;
esac
Permissions:
sudo chmod 0755 /usr/local/sbin/ddcci8-brightness
Sudoers: /etc/sudoers.d/ddcci8-brightness
This is intentionally narrow:
kaixin ALL=(root) NOPASSWD: /usr/local/sbin/ddcci8-brightness up, /usr/local/sbin/ddcci8-brightness down, /usr/local/sbin/ddcci8-brightness set *
Validate:
sudo chmod 0440 /etc/sudoers.d/ddcci8-brightness
sudo visudo -c
4) Hyprland Key Bindings
In ~/.config/hypr/hyprland.conf:
binde = , XF86MonBrightnessUp, exec, sudo -n /usr/local/sbin/ddcci8-brightness up
binde = , XF86MonBrightnessDown, exec, sudo -n /usr/local/sbin/ddcci8-brightness down
Reload:
hyprctl reload
Validation Checklist
After boot (within ~10 seconds)
ls /sys/class/backlight | grep -E '^ddcci[0-9]+$' || echo "no ddcci backlight"
test -L /run/backlight-ddp2 && ls -la /run/backlight-ddp2 || echo "no /run/backlight-ddp2"
cat /var/lib/ddcci-dp2/bus
journalctl -b -u ddcci-dp2-autofix.service --no-pager -n 50
Healthy log patterns include:
-
Backlight already present:
backlight already present (cached): ddcci8; linked /run/backlight-ddp2
-
Self-heal (after module unload / boot race):
use cached bus: i2c-8reload ddcci_backlightok after reload: /sys/class/backlight/ddcci8 exists; linked /run/backlight-ddp2; cached bus=8
Troubleshooting Notes
1) If you don’t see ddcci* under /sys/class/backlight
Try:
sudo systemctl restart ddcci-dp2-autofix.service
journalctl -b -u ddcci-dp2-autofix.service --no-pager -n 80
2) If your connector is not DP-2
Edit the script:
DP_SUFFIX="DP-2"
Change to DP-1, HDMI-A-1, etc., based on:
hyprctl monitors
3) If brightness keys do nothing
Test wrapper directly:
sudo -n /usr/local/sbin/ddcci8-brightness up
sudo -n /usr/local/sbin/ddcci8-brightness down
Closing Thoughts
The real win here is splitting responsibilities:
- Boot-time reliability: solved by systemd + self-healing script
- Runtime latency: solved by kernel backlight + brightnessctl
- Security: solved by a narrow wrapper + minimal sudoers
- Maintainability: solved by
/run/backlight-ddp2indirection + persistent bus cache
This setup has survived reboots, module reloads, and the annoying “ddcci_backlight didn’t probe at boot” race—and still feels instant under Hyprland.
::contentReference[oaicite:1]{index=1}
Enjoy Reading This Article?
Here are some more articles you might like to read next: