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/Down to 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:

  1. Low latency brightness key presses
  2. Robust boot-time behavior (it should “just work” every boot)
  3. Minimal privilege exposure (no broad video group / 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 keyssudo -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:

  • brightnessctl
  • ddcutil
  • ddcci-driver-linux (Fedora often via akmod-ddcci-driver-linux)
  • kernel modules:

    • ddcci
    • ddcci_backlight

Files We Keep (Final)

  1. Autofix script (root) /usr/local/sbin/ddcci-dp2-autofix

  2. systemd service (root) /etc/systemd/system/ddcci-dp2-autofix.service

  3. Brightness wrapper (root) /usr/local/sbin/ddcci8-brightness

  4. 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. contains 8)

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_device becomes writable
  • Only if cache isn’t usable, fall back to ddcutil detect to map DP-2 → i2c-N
  • Ensure an I2C client exists at address 0x37 (ddcci 0x37)
  • Reload ddcci_backlight until /sys/class/backlight/ddcci<bus> appears
  • Create /run/backlight-ddp2 and 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-8
    • reload ddcci_backlight
    • ok 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-ddp2 indirection + 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:

  • Fundamental Tensor and Related Operations
  • Math Rendering by Jekyll and MathJax
  • Chapter 1 Martingales
  • A test post
  • Chapter 10 Mathematical Statistics