|
#!/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 |