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.
- Internal Mode (Default): Intercepts the input and directly reads or writes to the
/sys/class/backlight/nvidia_0kernel 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. - External Mode (The
-eflag): 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:
-y: Automatically answer yes to prompts.2: The I2C bus number (/dev/i2c-2). You may need to runi2cdetect -lto find which bus your GPU is actually using.w7@0x37: The core directive. "Write (w)7bytes to I2C address0x37." In the DDC/CI standard, 0x37 is the universal fixed address for all monitors.
Next comes the 7-byte payload, dictated by the VESA DDC/CI spec:
0x51: Source Address. The computer host is always0x51.0x84: Message Length. Calculated as0x80 | Length. We are sending 4 bytes of data, so0x80 + 0x04 = 0x84.0x03: Opcode.0x03means "Set VCP Feature" (Virtual Control Panel).0x10: VCP Feature Code.0x10is the specific code for Brightness.0: High Byte. Brightness is 0-100, so the high byte is zero."$tgt": Low Byte. Your target brightness percentage."$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.