Skip to content

Instantly share code, notes, and snippets.

@andrescera
Last active December 2, 2025 23:07
Show Gist options
  • Select an option

  • Save andrescera/d82b815d3ce7eed0feeb7b18f0c4d25e to your computer and use it in GitHub Desktop.

Select an option

Save andrescera/d82b815d3ce7eed0feeb7b18f0c4d25e to your computer and use it in GitHub Desktop.
brightness-ddc-hyprland: Multi-monitor DDC/CI brightness control for Arch Linux with Waybar

DDC/CI Multi-Monitor Brightness Control for Arch Linux

Robust brightness control for external monitors using DDC/CI on Hyprland with Waybar integration.

Features

  • Multi-monitor support - Control all monitors simultaneously
  • Fast and responsive - Debounced hardware writes, instant UI feedback
  • Waybar integration - Visual indicator with scroll-to-adjust
  • Interactive menu - Click for detailed control with presets
  • Keyboard shortcuts - Standard brightness keys work out of the box
  • Race-condition safe - Proper locking for rapid key presses

Requirements

pacman -S ddcutil jq swayosd

Your monitors must support DDC/CI (most modern monitors do).

Installation

1. Main brightness script

curl -o ~/bin/brightness https://gist.githubusercontent.com/andrescera/d82b815d3ce7eed0feeb7b18f0c4d25e/raw/brightness
chmod +x ~/bin/brightness

# Edit DISPLAY_MAP to match your monitors
# Run `ddcutil detect` to find your display numbers

2. Waybar scripts

mkdir -p ~/.config/waybar/scripts

curl -o ~/.config/waybar/scripts/brightness.sh https://gist.githubusercontent.com/andrescera/d82b815d3ce7eed0feeb7b18f0c4d25e/raw/brightness.sh
curl -o ~/.config/waybar/scripts/brightness-menu.sh https://gist.githubusercontent.com/andrescera/d82b815d3ce7eed0feeb7b18f0c4d25e/raw/brightness-menu.sh

chmod +x ~/.config/waybar/scripts/brightness.sh
chmod +x ~/.config/waybar/scripts/brightness-menu.sh

3. Waybar config

Add to ~/.config/waybar/config.jsonc in modules-right:

"custom/brightness": {
  "format": "{}",
  "exec": "~/.config/waybar/scripts/brightness.sh",
  "return-type": "json",
  "interval": 2,
  "signal": 9,
  "on-click": "xdg-terminal-exec --app-id=brightness-menu -e ~/.config/waybar/scripts/brightness-menu.sh",
  "on-scroll-up": "~/bin/brightness up-all && pkill -RTMIN+9 waybar",
  "on-scroll-down": "~/bin/brightness down-all && pkill -RTMIN+9 waybar",
  "tooltip": true
},

Add to ~/.config/waybar/style.css:

#custom-brightness {
  font-size: 14px;
  font-weight: bold;
  margin: 0 8px;
  padding: 0 4px;
}

#custom-brightness:hover {
  background-color: rgba(255, 255, 255, 0.1);
  border-radius: 3px;
}

4. Hyprland keybindings

Add to ~/.config/hypr/hyprland.conf:

bindel = , XF86MonBrightnessUp, exec, ~/bin/brightness up-all
bindel = , XF86MonBrightnessDown, exec, ~/bin/brightness down-all
bindel = ALT, F2, exec, ~/bin/brightness up-all
bindel = ALT, F1, exec, ~/bin/brightness down-all

5. Configure your monitors

Run ddcutil detect and update the DISPLAY_MAP in all three scripts:

declare -A DISPLAY_MAP=(
    ["DP-2"]="1"
    ["DP-3"]="2"
)

Usage

brightness up-all          # Increase all monitors by 10%
brightness down-all        # Decrease all monitors by 10%
brightness set-all 50      # Set all monitors to 50%
brightness status          # Show all monitor brightness
brightness clear-cache     # Clear I2C bus cache

Waybar: Scroll to adjust, click to open menu.

Troubleshooting

# Monitors not detected
ddcutil detect

# Permission issues
sudo usermod -aG i2c $USER
# Then log out and back in

License

MIT

#!/bin/bash
# Modern brightness control for external DDC/CI monitors (2025)
# Simple, robust, handles rapid key presses
set -euo pipefail
# Configuration
readonly STEP=10
readonly STATE_DIR="/run/user/$(id -u)/brightness"
readonly CACHE_TTL=300 # 5 minutes
readonly DEBOUNCE_DELAY=0.15 # 150ms debounce for rapid presses
# Display mapping (Hyprland output -> ddcutil display number)
declare -A DISPLAY_MAP=(
["DP-2"]="1"
["DP-3"]="2"
)
# Ensure state directory exists
mkdir -p "$STATE_DIR"
# Get currently focused monitor from Hyprland
get_focused_monitor() {
hyprctl monitors -j 2>/dev/null | jq -r '.[] | select(.focused == true).name' || echo "DP-2"
}
# Get ddcutil display number for monitor
get_display_number() {
local monitor="$1"
echo "${DISPLAY_MAP[$monitor]:-1}"
}
# Get I2C bus for display (cached for performance)
get_i2c_bus() {
local display="$1"
local cache_file="$STATE_DIR/bus_${display}"
# Check cache validity
if [[ -f "$cache_file" ]]; then
local cache_age=$(( $(date +%s) - $(stat -c %Y "$cache_file") ))
if [[ $cache_age -lt $CACHE_TTL ]]; then
cat "$cache_file"
return 0
fi
fi
# Detect and cache bus number
local bus
bus=$(ddcutil detect 2>/dev/null | \
grep -A10 "^Display $display" | \
grep "I2C bus:" | \
awk '{print $3}' | \
sed 's#/dev/i2c-##')
if [[ -n "$bus" ]]; then
echo "$bus" > "$cache_file"
echo "$bus"
return 0
fi
return 1
}
# Get current brightness from hardware
get_brightness() {
local display="$1"
local bus
# Try to use cached bus for speed
if bus=$(get_i2c_bus "$display"); then
ddcutil --bus "$bus" getvcp 10 2>/dev/null | \
awk '/current value/ {match($0, /current value = *([0-9]+)/, arr); print arr[1]}'
else
# Fallback to display number
ddcutil --display "$display" getvcp 10 2>/dev/null | \
awk '/current value/ {match($0, /current value = *([0-9]+)/, arr); print arr[1]}'
fi
}
# Set brightness on hardware
set_brightness() {
local display="$1"
local value="$2"
local bus
# Validate value
if [[ $value -lt 0 ]]; then value=0; fi
if [[ $value -gt 100 ]]; then value=100; fi
# Try to use cached bus for speed
if bus=$(get_i2c_bus "$display"); then
ddcutil --bus "$bus" --noverify setvcp 10 "$value" 2>/dev/null
else
# Fallback to display number
ddcutil --display "$display" --noverify setvcp 10 "$value" 2>/dev/null
fi
}
# Show OSD notification
show_osd() {
local monitor="$1"
local percent="$2"
local ratio
ratio=$(awk "BEGIN {printf \"%.2f\", $percent/100}")
# Kill any existing OSD for this monitor to prevent stacking
pkill -f "swayosd-client.*--monitor $monitor" 2>/dev/null || true
swayosd-client \
--monitor "$monitor" \
--custom-icon display-brightness \
--custom-progress-text "Brightness: ${percent}%" \
--custom-progress "$ratio" 2>/dev/null &
}
# Main command handlers
cmd_change() {
local action="$1" # up or down
local target="${2:-focused}" # focused or all
# If target is "all", apply to all monitors
if [[ "$target" == "all" ]]; then
for monitor in "${!DISPLAY_MAP[@]}"; do
cmd_change_single "$action" "$monitor" &
done
wait
return 0
fi
# Otherwise, just change focused monitor
local monitor=$(get_focused_monitor)
cmd_change_single "$action" "$monitor"
}
cmd_change_single() {
local action="$1" # up or down
local monitor="$2"
local display lock_file state_file current new
display=$(get_display_number "$monitor")
lock_file="$STATE_DIR/${monitor}.lock"
state_file="$STATE_DIR/${monitor}.state"
# Use flock for atomic operations - try to get lock, skip if busy
exec 200>"$lock_file"
if ! flock -n 200; then
# Another process is already handling brightness, queue this change
# by updating the pending action file
echo "$action" > "$STATE_DIR/${monitor}.pending"
exit 0
fi
# Check if there's a pending action and merge it
local pending_file="$STATE_DIR/${monitor}.pending"
if [[ -f "$pending_file" ]]; then
rm -f "$pending_file"
fi
# Get current brightness (use cached state if available and recent)
if [[ -f "$state_file" ]]; then
local state_age=$(( $(date +%s) - $(stat -c %Y "$state_file") ))
if [[ $state_age -lt 2 ]]; then
current=$(cat "$state_file")
fi
fi
# If no cached state or stale, read from hardware
if [[ -z "${current:-}" ]]; then
current=$(get_brightness "$display")
if [[ -z "$current" ]]; then
flock -u 200
echo "Error: Could not read brightness" >&2
return 1
fi
fi
# Calculate new value
case "$action" in
up|raise)
new=$((current + STEP))
[[ $new -gt 100 ]] && new=100
;;
down|lower)
new=$((current - STEP))
[[ $new -lt 0 ]] && new=0
;;
*)
flock -u 200
echo "Error: Invalid action '$action'" >&2
return 1
;;
esac
# Save new state immediately
echo "$new" > "$state_file"
# Show OSD immediately with new value
show_osd "$monitor" "$new"
# Update Waybar if running
pkill -RTMIN+9 waybar 2>/dev/null || true
# Kill any existing background applier
local apply_pid_file="$STATE_DIR/${monitor}.apply_pid"
if [[ -f "$apply_pid_file" ]]; then
local old_pid=$(cat "$apply_pid_file" 2>/dev/null || true)
if [[ -n "$old_pid" ]]; then
kill "$old_pid" 2>/dev/null || true
fi
fi
# Apply to hardware in background with debounce
(
sleep "$DEBOUNCE_DELAY"
# Read final value (might have changed during sleep)
local final_value=$(cat "$state_file" 2>/dev/null || echo "$new")
# Apply to hardware
set_brightness "$display" "$final_value"
# Clean up
rm -f "$apply_pid_file"
) &
echo $! > "$apply_pid_file"
# Release lock
flock -u 200
echo "$new"
}
cmd_get() {
local monitor display brightness
if [[ -n "${1:-}" ]]; then
monitor="$1"
else
monitor=$(get_focused_monitor)
fi
display=$(get_display_number "$monitor")
brightness=$(get_brightness "$display")
if [[ -n "$brightness" ]]; then
echo "$monitor: $brightness%"
else
echo "Error: Could not read brightness for $monitor" >&2
return 1
fi
}
cmd_set() {
local value="$1"
local monitor display
monitor=$(get_focused_monitor)
display=$(get_display_number "$monitor")
if set_brightness "$display" "$value"; then
show_osd "$monitor" "$value"
echo "Set $monitor brightness to $value%"
else
echo "Error: Failed to set brightness" >&2
return 1
fi
}
cmd_status() {
echo "Brightness Status:"
for monitor in "${!DISPLAY_MAP[@]}"; do
display="${DISPLAY_MAP[$monitor]}"
brightness=$(get_brightness "$display" 2>/dev/null || echo "error")
printf " %-8s: %s\n" "$monitor" "$brightness"
done
}
cmd_clear_cache() {
rm -f "$STATE_DIR"/bus_*
rm -f "$STATE_DIR"/*.state
rm -f "$STATE_DIR"/*.pending
echo "Cache cleared"
}
# Command line interface
case "${1:-}" in
up|raise)
cmd_change up "${2:-focused}"
;;
down|lower)
cmd_change down "${2:-focused}"
;;
up-all|raise-all)
cmd_change up all
;;
down-all|lower-all)
cmd_change down all
;;
get)
cmd_get "${2:-}"
;;
set)
if [[ -z "${2:-}" ]]; then
echo "Error: set requires a value (0-100)" >&2
exit 1
fi
cmd_set "$2" "${3:-focused}"
;;
set-all)
if [[ -z "${2:-}" ]]; then
echo "Error: set-all requires a value (0-100)" >&2
exit 1
fi
for monitor in "${!DISPLAY_MAP[@]}"; do
display=$(get_display_number "$monitor")
set_brightness "$display" "$2" &
done
wait
show_osd "$(get_focused_monitor)" "$2"
echo "Set all monitors to $2%"
;;
status)
cmd_status
;;
clear-cache)
cmd_clear_cache
;;
*)
cat <<EOF
Usage: $(basename "$0") COMMAND [ARGS]
Commands:
up, raise Increase brightness by $STEP% (focused monitor)
down, lower Decrease brightness by $STEP% (focused monitor)
up-all, raise-all Increase brightness on ALL monitors
down-all, lower-all Decrease brightness on ALL monitors
get [MONITOR] Get current brightness
set VALUE [TARGET] Set brightness to VALUE (TARGET: focused/all)
set-all VALUE Set all monitors to VALUE
status Show brightness for all monitors
clear-cache Clear I2C bus cache
Examples:
$(basename "$0") up # Increase focused monitor
$(basename "$0") up-all # Increase all monitors
$(basename "$0") set 50 # Set focused to 50%
$(basename "$0") set-all 50 # Set all monitors to 50%
$(basename "$0") status
EOF
exit 1
;;
esac
#!/bin/bash
# Interactive brightness control menu - Controls ALL monitors
clear
# Get all monitor brightnesses
get_all_brightness() {
local -A brightnesses
while IFS=: read -r monitor value; do
monitor=$(echo "$monitor" | xargs)
value=$(echo "$value" | grep -oP '\d+' || echo "50")
brightnesses["$monitor"]="$value"
done < <(~/bin/brightness status 2>/dev/null | grep -E "DP-")
# Return average for display
local total=0
local count=0
for mon in "${!brightnesses[@]}"; do
total=$((total + brightnesses[$mon]))
count=$((count + 1))
done
if [[ $count -gt 0 ]]; then
echo $((total / count))
else
echo 50
fi
}
# Color codes
BOLD='\033[1m'
CYAN='\033[36m'
GREEN='\033[32m'
YELLOW='\033[33m'
MAGENTA='\033[35m'
RESET='\033[0m'
# Draw a visual brightness bar
draw_bar() {
local value=$1
local width=50
local filled=$((value * width / 100))
local empty=$((width - filled))
echo -n "["
for ((i=0; i<filled; i++)); do echo -n "█"; done
for ((i=0; i<empty; i++)); do echo -n "░"; done
echo "]"
}
# Main menu
while true; do
clear
CURRENT=$(get_all_brightness)
echo -e "${BOLD}${CYAN}╔════════════════════════════════════════════════╗${RESET}"
echo -e "${BOLD}${CYAN}║ Multi-Monitor Brightness Control ║${RESET}"
echo -e "${BOLD}${CYAN}╚════════════════════════════════════════════════╝${RESET}\n"
echo -e "${MAGENTA}${BOLD}📺 Controlling: ALL MONITORS${RESET}"
echo -e "Average brightness: ${YELLOW}${CURRENT}%${RESET}\n"
draw_bar "$CURRENT"
echo ""
# Show individual monitor status
echo -e "\n${BOLD}Individual Status:${RESET}"
~/bin/brightness status | grep -E "DP-" | while IFS=: read -r mon val; do
echo -e " ${GREEN}${mon}${RESET}: ${val}"
done
echo -e "\n${BOLD}Quick Presets (ALL monitors):${RESET}"
echo " [1] 0% (Off)"
echo " [2] 25% (Low)"
echo " [3] 50% (Medium)"
echo " [4] 75% (High)"
echo " [5] 100% (Maximum)"
echo -e "\n${BOLD}Adjustments (ALL monitors):${RESET}"
echo " [+] Increase by 10%"
echo " [-] Decrease by 10%"
echo " [c] Custom value (0-100)"
echo -e "\n${BOLD}Other:${RESET}"
echo " [r] Refresh status"
echo " [q] Quit"
echo -ne "\n${BOLD}${CYAN}Choose an option:${RESET} "
read -n 1 choice
echo ""
case "$choice" in
1)
~/bin/brightness set-all 0 >/dev/null 2>&1
;;
2)
~/bin/brightness set-all 25 >/dev/null 2>&1
;;
3)
~/bin/brightness set-all 50 >/dev/null 2>&1
;;
4)
~/bin/brightness set-all 75 >/dev/null 2>&1
;;
5)
~/bin/brightness set-all 100 >/dev/null 2>&1
;;
+|=)
~/bin/brightness up-all >/dev/null 2>&1
;;
-)
~/bin/brightness down-all >/dev/null 2>&1
;;
c|C)
echo -ne "\n${CYAN}Enter brightness value (0-100) for ALL monitors:${RESET} "
read -r custom_value
if [[ "$custom_value" =~ ^[0-9]+$ ]] && [[ $custom_value -ge 0 ]] && [[ $custom_value -le 100 ]]; then
~/bin/brightness set-all "$custom_value" >/dev/null 2>&1
else
echo -e "${YELLOW}Invalid value. Press Enter to continue...${RESET}"
read
fi
;;
r|R)
# Just refresh by looping
;;
q|Q)
clear
exit 0
;;
*)
echo -e "${YELLOW}Invalid option. Press Enter to continue...${RESET}"
read
;;
esac
# Small delay to show the change
sleep 0.3
done
#!/bin/bash
# Waybar brightness indicator for DDC monitors - Shows average across all monitors
STATE_DIR="/run/user/$(id -u)/brightness"
# Get average brightness from all monitors
get_average_brightness() {
local total=0
local count=0
local -A monitors=(["DP-2"]="1" ["DP-3"]="2")
for monitor in "${!monitors[@]}"; do
local state_file="$STATE_DIR/${monitor}.state"
if [[ -f "$state_file" ]]; then
# Use cached state if recent
local state_age=$(( $(date +%s) - $(stat -c %Y "$state_file" 2>/dev/null || echo 0) ))
if [[ $state_age -lt 3 ]]; then
local val=$(cat "$state_file")
total=$((total + val))
count=$((count + 1))
fi
fi
done
# If no cached data, query from hardware
if [[ $count -eq 0 ]]; then
while IFS=: read -r mon val; do
val=$(echo "$val" | grep -oP '\d+' || echo "0")
if [[ $val -gt 0 ]]; then
total=$((total + val))
count=$((count + 1))
fi
done < <(~/bin/brightness status 2>/dev/null | grep -E "DP-")
fi
if [[ $count -gt 0 ]]; then
echo $((total / count))
else
echo 50
fi
}
BRIGHTNESS=$(get_average_brightness)
# Choose icon based on brightness level
if [[ $BRIGHTNESS -ge 80 ]]; then
ICON="󰃠" # High brightness
elif [[ $BRIGHTNESS -ge 40 ]]; then
ICON="󰃟" # Medium brightness
elif [[ $BRIGHTNESS -gt 0 ]]; then
ICON="󰃞" # Low brightness
else
ICON="󰃜" # Off/very low
fi
# Build tooltip with helpful info
TOOLTIP="💡 Average Brightness: ${BRIGHTNESS}%\n"
TOOLTIP+="📺 Controls: ALL MONITORS\n\n"
TOOLTIP+="🖱️ Scroll to adjust all (±10%)\n"
TOOLTIP+="🖱️ Click for detailed menu"
# Output JSON for Waybar
echo "{\"text\":\"${ICON} ${BRIGHTNESS}%\",\"tooltip\":\"${TOOLTIP}\",\"class\":\"brightness\",\"percentage\":${BRIGHTNESS}}"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment