Last active
September 11, 2025 21:21
-
-
Save kjanat/0efbcfef3026498bb9f21cd1518dd001 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # brightness.sh - DDC/CI Monitor Brightness Control Utility | |
| # | |
| # Intelligent monitor brightness control with sophisticated caching system. | |
| # Supports multiple monitors, relative adjustments, and atomic cache operations. | |
| # | |
| # Installation: | |
| # curl -o ~/.local/bin/brightness https://gist.github.com/kjanat/0efbcfef3026498bb9f21cd1518dd001/raw/brightness.sh | |
| # chmod +x ~/.local/bin/brightness | |
| # (Ensure ~/.local/bin is in your PATH) | |
| # | |
| # Dependencies: ddcutil (for DDC/CI communication) | |
| # Platform: Requires GNU coreutils (GNU/Linux); macOS/BSD users may need gstat or alternative flags | |
| # Permissions: User must be in 'i2c' group for DDC access | |
| # Performance: Uses 1-hour monitor cache and 2-second brightness cache | |
| # | |
| # Cache file for monitor detection (valid for 1 hour) | |
| CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/brightness" | |
| CACHE_FILE="$CACHE_DIR/monitors.cache" | |
| CACHE_MAX_AGE=3600 # 1 hour in seconds | |
| # Create cache directory if needed | |
| mkdir -p "$CACHE_DIR" | |
| # Check for ddcutil dependency | |
| if ! command -v ddcutil >/dev/null 2>&1; then | |
| echo "Error: ddcutil is not installed or not in PATH" >&2 | |
| echo "Installation instructions:" >&2 | |
| echo " Ubuntu/Debian: sudo apt install ddcutil" >&2 | |
| echo " Arch Linux: sudo pacman -S ddcutil" >&2 | |
| echo " Fedora: sudo dnf install ddcutil" >&2 | |
| echo " Or visit: https://www.ddcutil.com/install/" >&2 | |
| exit 1 | |
| fi | |
| # Check for common i2c setup issues (non-fatal warnings) | |
| if ! lsmod 2>/dev/null | grep -q "i2c_dev" && [ ! -d "/sys/module/i2c_dev" ]; then | |
| echo "Warning: i2c-dev kernel module may not be loaded" >&2 | |
| echo " Try: sudo modprobe i2c-dev" >&2 | |
| echo " Or add 'i2c-dev' to /etc/modules for auto-loading" >&2 | |
| fi | |
| if ! groups 2>/dev/null | grep -q "i2c" && ! ls /dev/i2c-* >/dev/null 2>&1; then | |
| echo "Warning: No i2c devices found or user may lack permissions" >&2 | |
| echo " Try: sudo usermod -a -G i2c \$(whoami)" >&2 | |
| echo " Then logout/login, or run: newgrp i2c" >&2 | |
| elif ! groups 2>/dev/null | grep -q "i2c" && ls /dev/i2c-* >/dev/null 2>&1; then | |
| # Check if any i2c device is readable | |
| device_readable=false | |
| for device in /dev/i2c-*; do | |
| if [ -r "$device" ]; then | |
| device_readable=true | |
| break | |
| fi | |
| done | |
| if [ "$device_readable" = "false" ]; then | |
| echo "Warning: User not in i2c group, DDC access may fail" >&2 | |
| echo " Try: sudo usermod -a -G i2c \$(whoami) && newgrp i2c" >&2 | |
| fi | |
| fi | |
| # Function to get cached or fresh monitor data | |
| # Args: $1 - force_refresh (true/false) | |
| # Returns: Pipe-delimited monitor data (display|mfg|model|serial) | |
| # Uses 1-hour cache to avoid expensive ddcutil detect calls | |
| get_monitors() { | |
| local force_refresh=${1:-false} | |
| # Check if cache exists and is fresh | |
| if [ "$force_refresh" = "false" ] && [ -f "$CACHE_FILE" ]; then | |
| local cache_age=$(($(date +%s) - $(stat -c %Y "$CACHE_FILE" 2>/dev/null || echo 0))) | |
| if [ "$cache_age" -lt "$CACHE_MAX_AGE" ]; then | |
| cat "$CACHE_FILE" | |
| return | |
| fi | |
| fi | |
| # Use --terse for faster detection (0.7s vs 0.9s) | |
| local detect_output | |
| detect_output=$(ddcutil detect --terse 2>/dev/null) | |
| if [ -z "$detect_output" ]; then | |
| echo "Error: No monitors detected or ddcutil failed" >&2 | |
| echo "Troubleshooting tips:" >&2 | |
| echo " - Ensure ddcutil is installed: sudo apt install ddcutil" >&2 | |
| echo " - Check user permissions: sudo usermod -a -G i2c \$(whoami)" >&2 | |
| echo " - Ensure kernel module 'i2c-dev' is enabled/loaded" >&2 | |
| echo " - Test DDC communication: ddcutil detect" >&2 | |
| exit 1 | |
| fi | |
| # Parse and cache the output | |
| echo "$detect_output" | awk ' | |
| /^Display [0-9]/ { | |
| display = $2 | |
| } | |
| /Monitor:/ { | |
| # Extract substring after "Monitor:" regardless of spacing/tokenization | |
| monitor_pos = index($0, "Monitor:") | |
| if (monitor_pos > 0) { | |
| monitor_info = substr($0, monitor_pos + 8) # Skip "Monitor:" | |
| gsub(/^[ \t]+/, "", monitor_info) # Trim leading whitespace | |
| split(monitor_info, parts, ":") | |
| mfg = parts[1] | |
| model = parts[2] | |
| serial = parts[3] | |
| printf "%s|%s|%s|%s\n", display, mfg, model, serial | |
| } | |
| } | |
| ' | { | |
| # Atomic write with exclusive lock to prevent concurrent writers | |
| ( | |
| flock 200 | |
| sort -t'|' -k1,1n | tee "${CACHE_FILE}.tmp" && mv "${CACHE_FILE}.tmp" "$CACHE_FILE" | |
| ) 200>"${CACHE_FILE}.lock" | |
| } | |
| } | |
| # Function to validate brightness value | |
| # Args: $1 - brightness value to validate | |
| # Returns: 0 if valid (0-100), 1 if invalid | |
| validate_brightness() { | |
| local val=$1 | |
| if ! [[ "$val" =~ ^[0-9]+$ ]] || [ "$val" -lt 0 ] || [ "$val" -gt 100 ]; then | |
| return 1 | |
| fi | |
| return 0 | |
| } | |
| # Function to get current brightness with caching | |
| # Args: $1 - display number | |
| # Returns: Current brightness value (0-100), or empty if unable to read | |
| # Uses 2-second cache for responsive queries, validates cached values | |
| get_brightness() { | |
| local display=$1 | |
| local cache_key="$CACHE_DIR/brightness_${display}.tmp" | |
| # Use cached value if it's less than 2 seconds old | |
| if [ -f "$cache_key" ]; then | |
| local cache_age=$(($(date +%s) - $(stat -c %Y "$cache_key" 2>/dev/null || echo 0))) | |
| if [ "$cache_age" -lt 2 ]; then | |
| local cached_value | |
| cached_value=$(cat "$cache_key") | |
| if validate_brightness "$cached_value"; then | |
| echo "$cached_value" | |
| return | |
| else | |
| # Invalid cached value, remove it | |
| rm -f "$cache_key" | |
| fi | |
| fi | |
| fi | |
| # Get fresh value and cache it | |
| local brightness | |
| brightness=$(ddcutil getvcp 10 --display "$display" --brief 2>/dev/null | awk '{print $4}') | |
| if [ -n "$brightness" ] && validate_brightness "$brightness"; then | |
| # Atomic cache write | |
| echo "$brightness" >"${cache_key}.tmp" && mv "${cache_key}.tmp" "$cache_key" | |
| echo "$brightness" | |
| elif [ -n "$brightness" ]; then | |
| # Invalid brightness value, don't cache | |
| echo "Warning: Invalid brightness value '$brightness' from display $display" >&2 | |
| rm -f "$cache_key" | |
| fi | |
| } | |
| # Function to set brightness with retry logic | |
| # Args: $1 - display number, $2 - brightness value (0-100) | |
| # Returns: 0 on success, 1 on failure (including invalid input) | |
| # Uses input validation, optional 2s timeout, 2-attempt retry with 0.1s delay | |
| set_brightness() { | |
| local display=$1 | |
| local value=$2 | |
| local retries=2 | |
| # Defensive input validation | |
| if ! validate_brightness "$value"; then | |
| return 1 | |
| fi | |
| while [ "$retries" -gt 0 ]; do | |
| # Use timeout to prevent hangs on flaky DDC links | |
| if command -v timeout >/dev/null 2>&1; then | |
| if timeout 2s ddcutil setvcp 10 "$value" --display "$display" --noverify 2>/dev/null; then | |
| # Clear brightness cache for this display | |
| rm -f "$CACHE_DIR/brightness_${display}.tmp" | |
| return 0 | |
| fi | |
| else | |
| if ddcutil setvcp 10 "$value" --display "$display" --noverify 2>/dev/null; then | |
| # Clear brightness cache for this display | |
| rm -f "$CACHE_DIR/brightness_${display}.tmp" | |
| return 0 | |
| fi | |
| fi | |
| retries=$((retries - 1)) | |
| [ "$retries" -gt 0 ] && sleep 0.1 | |
| done | |
| return 1 | |
| } | |
| # Parse command line arguments | |
| FORCE_REFRESH=false | |
| while [[ $# -gt 0 ]]; do | |
| case $1 in | |
| --refresh | -r) | |
| FORCE_REFRESH=true | |
| shift | |
| ;; | |
| --clear-cache) | |
| CLEAR_CACHE=true | |
| shift | |
| ;; | |
| --help) | |
| cat <<EOF | |
| Usage: brightness [OPTIONS] [VALUE|+VALUE|-VALUE] | |
| Control monitor brightness via DDC/CI | |
| Options: | |
| --refresh, -r Force refresh monitor detection cache | |
| --clear-cache Clear all cached data and start fresh | |
| --help Show this help message | |
| Arguments: | |
| (no args) Show current brightness for all monitors | |
| VALUE Set brightness to VALUE (0-100) | |
| +VALUE Increase brightness by VALUE | |
| -VALUE Decrease brightness by VALUE | |
| Examples: | |
| brightness # Show current brightness | |
| brightness 70 # Set all monitors to 70% | |
| brightness +10 # Increase by 10% | |
| brightness -5 # Decrease by 5% | |
| Cache is stored in: $CACHE_DIR | |
| EOF | |
| exit 0 | |
| ;; | |
| --*) | |
| echo "Error: Unknown option '$1'" >&2 | |
| echo "Try 'brightness --help' for usage information" >&2 | |
| exit 1 | |
| ;; | |
| *) | |
| break | |
| ;; | |
| esac | |
| done | |
| # Clear cache if requested | |
| if [ "$CLEAR_CACHE" = "true" ]; then | |
| echo "Clearing cache directory: $CACHE_DIR" | |
| rm -rf "$CACHE_DIR"/*.cache "$CACHE_DIR"/*.tmp "$CACHE_DIR"/*.lock 2>/dev/null || true | |
| echo "Cache cleared!" | |
| [ -z "${1:-}" ] && exit 0 | |
| fi | |
| # Get monitor information | |
| MONITORS=$(get_monitors "$FORCE_REFRESH") | |
| if [ -z "$MONITORS" ]; then | |
| echo "Error: No monitors found" | |
| exit 1 | |
| fi | |
| # Count monitors | |
| COUNT=$(echo "$MONITORS" | wc -l) | |
| # Main logic based on argument | |
| case "${1:-}" in | |
| "") | |
| # Show current brightness | |
| echo "Brightness levels ($COUNT monitor(s)):" | |
| echo "" | |
| # Process monitors in parallel for faster response with stable ordering | |
| temp_dir=$(mktemp -d) | |
| trap 'rm -rf "$temp_dir"' EXIT | |
| index=0 | |
| while IFS='|' read -r display mfg model _; do | |
| { | |
| brightness=$(get_brightness "$display") | |
| if [ -n "$brightness" ]; then | |
| echo "Display $display ($mfg $model): $brightness%" >"$temp_dir/$index" | |
| else | |
| echo "Display $display ($mfg $model): Unable to read" >"$temp_dir/$index" | |
| fi | |
| } & | |
| index=$((index + 1)) | |
| done <<<"$MONITORS" | |
| wait | |
| # Output results in original order | |
| for ((i = 0; i < index; i++)); do | |
| cat "$temp_dir/$i" | |
| done | |
| rm -rf "$temp_dir" | |
| ;; | |
| +*) | |
| # Increase brightness | |
| VAL=${1#+} | |
| if ! validate_brightness "$VAL"; then | |
| echo "Error: Invalid increment value. Must be 0-100" | |
| exit 1 | |
| fi | |
| echo "Increasing brightness by $VAL%..." | |
| success=0 | |
| failed=0 | |
| while IFS='|' read -r display mfg model _; do | |
| current=$(get_brightness "$display") | |
| if [ -n "$current" ]; then | |
| new=$((current + VAL)) | |
| [ "$new" -gt 100 ] && new=100 | |
| if set_brightness "$display" "$new"; then | |
| echo "Display $display: $current% → $new%" | |
| success=$((success + 1)) | |
| else | |
| echo "Display $display: Failed to set brightness" | |
| failed=$((failed + 1)) | |
| fi | |
| else | |
| echo "Display $display: Cannot read current value" | |
| failed=$((failed + 1)) | |
| fi | |
| done <<<"$MONITORS" | |
| echo "Brightness update: $success succeeded, $failed failed" | |
| [ "$failed" -gt 0 ] && exit 1 | |
| ;; | |
| -*) | |
| # Decrease brightness | |
| VAL=${1#-} | |
| if ! validate_brightness "$VAL"; then | |
| echo "Error: Invalid decrement value. Must be 0-100" | |
| exit 1 | |
| fi | |
| echo "Decreasing brightness by $VAL%..." | |
| success=0 | |
| failed=0 | |
| while IFS='|' read -r display mfg model _; do | |
| current=$(get_brightness "$display") | |
| if [ -n "$current" ]; then | |
| new=$((current - VAL)) | |
| [ "$new" -lt 0 ] && new=0 | |
| if set_brightness "$display" "$new"; then | |
| echo "Display $display: $current% → $new%" | |
| success=$((success + 1)) | |
| else | |
| echo "Display $display: Failed to set brightness" | |
| failed=$((failed + 1)) | |
| fi | |
| else | |
| echo "Display $display: Cannot read current value" | |
| failed=$((failed + 1)) | |
| fi | |
| done <<<"$MONITORS" | |
| echo "Brightness update: $success succeeded, $failed failed" | |
| [ "$failed" -gt 0 ] && exit 1 | |
| ;; | |
| *) | |
| # Set absolute brightness | |
| if ! validate_brightness "$1"; then | |
| echo "Error: Brightness must be between 0-100, got: '$1'" >&2 | |
| echo "Examples: brightness 70, brightness +10, brightness -5" >&2 | |
| echo "Try 'brightness --help' for full usage information" >&2 | |
| exit 1 | |
| fi | |
| echo "Setting brightness to $1%..." | |
| # Set all monitors in sequence (more reliable than parallel) | |
| success=0 | |
| failed=0 | |
| while IFS='|' read -r display mfg model _; do | |
| if set_brightness "$display" "$1"; then | |
| echo "Display $display: Set to $1%" | |
| success=$((success + 1)) | |
| else | |
| echo "Display $display: Failed to set brightness" | |
| failed=$((failed + 1)) | |
| fi | |
| done <<<"$MONITORS" | |
| if [ "$failed" -gt 0 ]; then | |
| echo "Error: $failed monitor(s) failed to update" | |
| exit 1 | |
| else | |
| echo "All monitors updated successfully!" | |
| fi | |
| ;; | |
| esac |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment