How Do I Manage My Monitor Brightness

2026-05-01

If you run a custom Linux desktop environment—especially a Wayland compositor like Sway or Hyprland—you know the struggle of managing screen brightness.

For laptop displays, standard tools like brightnessctl work perfectly. But plug in an external HDMI or DisplayPort monitor, and those tools are suddenly useless. The standard solution is to install ddcutil, a heavy utility that communicates with your monitor via the DDC/CI protocol. However, ddcutil is notoriously slow; binding it to your keyboard's brightness keys often results in frustrating lag.

To solve this, I use a brilliantly minimalist alternative: a tiny shell script that bypasses bloated utilities entirely, using standard i2c-tools to inject raw DDC/CI hex codes directly into the monitor's I2C bus.

Here is the exact script I use:

#!/bin/sh

mode=in
[ "$1" = -e ] && { mode=ex; shift; }

val="$1"
case $val in [0-9]*) ;; *) echo "usage: bl [-e] n[%][-/+]" >&2; exit 1 ;; esac

if [ "$mode" = in ]; then
    dev=/sys/class/backlight/nvidia_0
    [ -e "$dev" ] || { echo "no backlight class" >&2; exit 1; }

    read -r cur < "$dev/brightness"
    read -r max < "$dev/max_brightness"

    case $val in
        *+) num=${val%+}; num=${num%%%}; cur=$(( cur + num * max / 100 )) ;;
        *-) num=${val%-}; num=${num%%%}; cur=$(( cur - num * max / 100 )) ;;
        *)  num=${val%%%}; cur=$(( num * max / 100 )) ;;
    esac

    [ "$cur" -gt "$max" ] && cur=$max
    [ "$cur" -lt 0 ] && cur=0

    echo "$cur" > "$dev/brightness" || { echo "write failed" >&2; exit 1; }
    res=$cur
else
    cache=$XDG_STATE_HOME/brightness-ddc
    read -r cur < "$cache" 2>/dev/null || cur=0

    case $val in
        *+) tgt=$(( cur + ${val%%[!0-9]*} )) ;;
        *-) tgt=$(( cur - ${val%%[!0-9]*} )) ;;
        *)  tgt=${val%%[!0-9]*} ;;
    esac

    [ "$tgt" -lt 0 ] && tgt=0
    [ "$tgt" -gt 100 ] && tgt=100

    chk=$(( 0x6E ^ 0x51 ^ 0x84 ^ 0x03 ^ 0x10 ^ 0 ^ tgt ))
    i2ctransfer -y 2 w7@0x37 0x51 0x84 0x03 0x10 0 "$tgt" "$chk" || { echo "DDC write failed" >&2; exit 1; }

    echo "$tgt" > "$cache"
    res=$tgt
fi

fyi -r 998 -H int:value:"$res" Brightness

Let's break down how this elegant piece of code works.


The Architecture: A Dual-Mode Wrapper

At its core, the script is designed to be the single entry point for window manager brightness keybindings.

  1. Internal Mode (Default): Intercepts the input and directly reads or writes to the /sys/class/backlight/nvidia_0 kernel interface to adjust the laptop's built-in screen brightness. It supports both absolute percentage settings and relative adjustments (e.g., +10% or -5%), features built-in boundary protection to prevent underflow/overflow, and outputs the final adjusted brightness value.
  2. External Mode (The -e flag): It bypasses standard tools. Because I2C write commands are "blind" (reading from I2C is slow and error-prone), the script smartly uses a local cache file (~/.cache/brightness-ddc) to keep track of the current brightness state. It calculates the new target (tgt) based on your input (e.g., +5% or -10%), clamps it between 0 and 100, and sends it to the monitor.

Finally, no matter which mode is used, the resulting percentage is piped into ${XDG_RUNTIME_DIR}/wob.fifo. This perfectly integrates the script with WOB (Wayland Overlay Bar), instantly triggering a beautiful on-screen progress bar UI.


Prerequisites: Unlocking the I2C Bus

To make this script work, your Linux system needs to expose the hardware I2C bus (routed through your GPU to the display cable) to user space.

1. Load the i2c_dev module The Linux kernel uses the i2c_dev module to create device nodes (like /dev/i2c-2). You need to load it:

sudo modprobe i2c_dev

Tip: To make this persistent across reboots, add i2c-dev to a .conf file inside /etc/modules-load.d/.

2. Join the i2c group By default, only the root user can write to hardware devices. Running your brightness script with sudo every time you press a hotkey is a terrible idea. Fortunately, most distributions create an i2c user group for exactly this purpose. Add your user to this group to grant root-less access:

sudo usermod -aG i2c $USER

(Remember to log out and log back in for the group change to take effect).


The Black Magic: Constructing the I2C Payload

The absolute coolest part of this script is how it completely manually constructs a DDC/CI data packet. Let's look at the two magical lines:

chk=$(( 0x6E ^ 0x51 ^ 0x84 ^ 0x03 ^ 0x10 ^ 0 ^ tgt ))
i2ctransfer -y 2 w7@0x37 0x51 0x84 0x03 0x10 0 "$tgt" "$chk"

Let's dissect the i2ctransfer command:

Next comes the 7-byte payload, dictated by the VESA DDC/CI spec:

  1. 0x51: Source Address. The computer host is always 0x51.
  2. 0x84: Message Length. Calculated as 0x80 | Length. We are sending 4 bytes of data, so 0x80 + 0x04 = 0x84.
  3. 0x03: Opcode. 0x03 means "Set VCP Feature" (Virtual Control Panel).
  4. 0x10: VCP Feature Code. 0x10 is the specific code for Brightness.
  5. 0: High Byte. Brightness is 0-100, so the high byte is zero.
  6. "$tgt": Low Byte. Your target brightness percentage.
  7. "$chk": Checksum. #### Understanding the XOR Checksum The DDC/CI protocol mandates that the final byte must be an XOR (^) checksum of all preceding bytes. If the checksum is wrong, the monitor ignores the command.

But look at the checksum formula in the script: chk=$(( 0x6E ^ 0x51 ^ ... ))

Where did 0x6E come from? We are writing to address 0x37, right? This is where low-level I2C knowledge shines. I2C uses 7-bit addresses, but the 8th bit is reserved for the Read/Write flag (0 for write, 1 for read).

When the hardware actually transmits to address 0x37 (0011 0111 in binary) for a write operation, it shifts the bits left by one and adds a 0 at the end. 0011 0111 shifted left becomes 0110 1110. Convert 0110 1110 back to hexadecimal, and you get exactly 0x6E.

The checksum correctly XORs the physical write address, followed by the rest of the payload.

Conclusion

This script is a masterclass in Unix minimalism. By stripping away abstraction layers and doing the bit-math directly in a shell script, it achieves true zero-latency brightness control for external monitors. It is the perfect companion for a lightweight Wayland setup, proving once again that sometimes, the best tool for the job is just a few lines of raw, unapologetic system commands.