#!/bin/bash # # backlight.sh -- match Pi LCD backlight to the dashboard dimmer via CAN. # # Original author: redracer (clever real-world use of $291 byte-6 # interior dimmer to drive Pi screen # brightness in lockstep with the dash) # Updates / polish: magikh0e # Last updated: 05.2026 # # Watches $291 on CAN-IHS for changes to the headlight / interior # dimmer state and mirrors the dashboard dimmer setting onto the # Raspberry Pi LCD backlight via /sys/class/backlight. The net effect: # when you dim your dashboard, the Pi's screen dims with it. # # Logic: # # Running lights OFF -> Pi backlight at $FULL_BRIGHTNESS (daytime) # Running lights ON -> Pi backlight scaled from the dashboard # dimmer value in byte 6 of $291. Range # clamps to a sane minimum so the screen # never goes completely black. # # See Bus & Message # Reference for the full $291 byte layout. # # USAGE # ./backlight.sh [--once] [--verbose] [--help] # # OPTIONS # --once Process one event and exit (testing) # -v, --verbose Echo every $291 frame and decode step to stderr # -h, --help Print this message # # REQUIRES # - can-utils (candump) apt install can-utils # - CAN-IHS interface up at 125 kbps: # ip link set $CAN_IHS up type can bitrate 125000 # - Raspberry Pi 7" Touch Display (or compatible) exposing # /sys/class/backlight/rpi_backlight/brightness # - Write permission on the brightness sysfs node (run as root, # or use a udev rule to grant the pi group write access) # # CONFIGURATION NOTES # The dashboard dimmer range observed on this platform is # 0x22..0xC8 hex (= 34..200 decimal). redracer's original script # used decimal 22 as the minimum and the scale factor 0.3, giving # a Pi-side range of about 7..60 over the full dashboard dim # range. Those values are preserved here as defaults but lifted # to named constants ($DIM_MIN, $SCALE_NUM, $SCALE_DEN, $OFFSET) # so you can re-tune for your specific screen if 7..60 doesn't # match its usable range. # # REFERENCE # https://magikh0e.pl/pubCarHacking/bus-message-reference.html#id-291 # Full $291 byte map -- byte 2 (running lights), byte 5 # (cabin lights), byte 6 (interior dimmer), byte 7 (head # lights). set -eu # --------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------- CAN_IHS=can1 # CAN-IHS interface CAN_ID=291 # $291 = light switch / dimmer BACKLIGHT_PATH=/sys/class/backlight/rpi_backlight/brightness FULL_BRIGHTNESS=199 # Pi backlight is 0-200 scale; # 199 = essentially full SCREEN_FLOOR=7 # Don't drive Pi backlight below # this; total-black is unusable DIM_MIN=22 # Subtracted from raw dimmer # value before scaling. Note: # redracer's original used # decimal 22 here even though # the BMR documents 0x22 (=34 # decimal) as the dashboard # minimum. Preserved for # compatibility with redracer's # observed behaviour on his # platform. SCALE_NUM=3 # Scale factor numerator: SCALE_DEN=10 # original used 0.3 = 3/10 RATE_LIMIT_SECONDS=1 # Process at most one $291 # frame per second CANDUMP_RETRY_DELAY=5 # Backoff for pipe reconnect # --------------------------------------------------------------------- # State # --------------------------------------------------------------------- ONCE=0 VERBOSE=0 LAST_TICK=-1 LAST_DIMLEVEL=$FULL_BRIGHTNESS CLEANED=0 # --------------------------------------------------------------------- # CLI # --------------------------------------------------------------------- usage() { cat <&2 echo "usage: $0 [--once] [--verbose] [--help]" >&2 exit 2 ;; esac shift done # --------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------- log() { echo "$*" >&2; } verbose() { [[ "$VERBOSE" -eq 1 ]] && log "$*" || true; } set_brightness() { local level="$1" if [[ ! -w "$BACKLIGHT_PATH" ]]; then log " warn: $BACKLIGHT_PATH not writable, skipping" return 0 fi echo "$level" > "$BACKLIGHT_PATH" } # Compute the Pi backlight level (integer 0..$FULL_BRIGHTNESS) from # the raw dashboard dimmer byte. Pure bash integer math; no `bc` # dependency. compute_dimlevel() { local raw="$1" local val=$(( (raw - DIM_MIN) * SCALE_NUM / SCALE_DEN + SCREEN_FLOOR )) # Clamp to [SCREEN_FLOOR, FULL_BRIGHTNESS] so we never overshoot # the hardware range or drive the screen completely black. (( val < SCREEN_FLOOR )) && val=$SCREEN_FLOOR (( val > FULL_BRIGHTNESS )) && val=$FULL_BRIGHTNESS echo "$val" } # --------------------------------------------------------------------- # Signal handling # --------------------------------------------------------------------- # On exit, restore the backlight to full so a crashed/killed script # never leaves the screen stuck at a dim setting. Better to default # to "you can see things" than "wonder why your screen is broken". cleanup() { [[ "$CLEANED" -eq 1 ]] && return 0 CLEANED=1 log "" log "[cleanup] restoring backlight to $FULL_BRIGHTNESS" set_brightness "$FULL_BRIGHTNESS" || true exit 0 } trap cleanup INT TERM # --------------------------------------------------------------------- # Pre-flight # --------------------------------------------------------------------- if ! ip link show "$CAN_IHS" >/dev/null 2>&1; then log "ERROR: CAN interface $CAN_IHS not found" log " Bring it up: ip link set $CAN_IHS up type can bitrate 125000" exit 1 fi if [[ ! -e "$BACKLIGHT_PATH" ]]; then log "ERROR: backlight sysfs node $BACKLIGHT_PATH does not exist" log " This script is built for the Raspberry Pi 7\" Touch Display." log " For a different display, edit BACKLIGHT_PATH at the top of" log " this script to point at your sysfs brightness node." exit 1 fi if [[ ! -w "$BACKLIGHT_PATH" ]]; then log "WARNING: $BACKLIGHT_PATH not writable as the current user." log " Either run this script as root, or add a udev rule" log " granting the pi group write access. Continuing anyway" log " (writes will fail silently)." fi if ! command -v candump >/dev/null 2>&1; then log "ERROR: candump not found. Install: apt install can-utils" exit 1 fi # --------------------------------------------------------------------- # Main # --------------------------------------------------------------------- log "[start] watching \$$CAN_ID on $CAN_IHS for dimmer changes" log "[start] Pi screen ${FULL_BRIGHTNESS} (off) -> ${SCREEN_FLOOR}..$(compute_dimlevel 200) (dimmest..brightest dashboard)" # Outer reconnect loop -- if the candump pipe ends (CAN drops, vehicle # sleeps fully), back off and reconnect. while true; do candump -L "$CAN_IHS,0${CAN_ID}:0FFF" 2>/dev/null \ | while read -r TIME BUS FRAME; do # Rate-limit so we don't recompute and write sysfs at the # broadcast cadence ($291 fires faster than dashboard dimmer # changes happen, so once per second is plenty). NOW=$SECONDS [[ "$NOW" -eq "$LAST_TICK" ]] && continue LAST_TICK=$NOW verbose "raw: $FRAME" payload="${FRAME##*#}" [[ ${#payload} -lt 16 ]] && continue # short frame # Per BMR #id-291: # byte 2 (chars 4-5) running lights # byte 6 (chars 12-13) interior dimmer # redracer's original tested only the LOW NIBBLE of byte 2, # not the full byte. That's because byte 2 sometimes carries # high-nibble state too -- BMR documents "0x01 = on" but # redracer observed both 0x01 and 0x03 as on (and possibly # 0x11 / 0x13 in some captures). Low nibble == 1 or 3 == # running lights on. Keep this looser check. runl_lownib="${payload:5:1}" dim_hex="${payload:12:2}" if ! [[ "$dim_hex" =~ ^[0-9A-Fa-f]{2}$ ]]; then verbose " skip: non-hex dimmer byte '$dim_hex'" continue fi dim_raw=$((16#$dim_hex)) # Pick brightness based on whether running lights are on. if [[ "$runl_lownib" == "1" ]] || [[ "$runl_lownib" == "3" ]]; then new_level=$(compute_dimlevel "$dim_raw") verbose " lights ON | dim_byte=0x$dim_hex ($dim_raw) -> level $new_level" else new_level=$FULL_BRIGHTNESS verbose " lights OFF | dim_byte=0x$dim_hex (ignored) -> level $new_level" fi # Only write if changed. if [[ "$new_level" != "$LAST_DIMLEVEL" ]]; then log "Dimming to $new_level / $FULL_BRIGHTNESS" set_brightness "$new_level" LAST_DIMLEVEL=$new_level fi [[ "$ONCE" -eq 1 ]] && cleanup done log "[reconnect] candump pipe ended; retrying in ${CANDUMP_RETRY_DELAY}s" sleep "$CANDUMP_RETRY_DELAY" done # REVISION NOTES (2026-05-16) # - Original by redracer: the $291-watch-and-dim core logic, the # dimmer-scaling math (with offset 22 and 0.3 scale factor), the # $SCREEN_FLOOR=7 minimum, and the design choice that # running-lights-off means full brightness (so the screen # reverts to "you can see it" any time the headlights are off). # Distinct author from the rest of the jmccorm scripts. # - This rewrite preserves redracer's exact brightness curve -- # same DIM_MIN, scale, SCREEN_FLOOR, FULL_BRIGHTNESS defaults -- # so anyone running the original script gets the same screen # behaviour from this version. Improvements: # * Removed `bc` dependency: integer math replaces the # `(DIMRAW - 22)*.3 | printf %.0f | +7` chain. # * Removed cut-style indexing in favour of bash parameter # expansion (${payload##*#}, ${payload:5:1}, etc). # * Added shebang + `set -eu`, --once / --verbose / --help CLI, # pre-flight CAN + backlight sysfs checks, outer reconnect # loop, SIGINT trap that restores full brightness on exit # (a crashed script no longer leaves the screen stuck dim). # * Bumped tunable constants to named variables at the top so # the brightness curve is editable in one place without # hunting through the loop body. # - The original's `[[ "$RUNL" == "3" || "$RUNL" == "1" ]]` # low-nibble check is preserved verbatim (renamed runl_lownib # for clarity). This is a useful refinement to the BMR -- $291 # byte 2 carries running-lights state in the LOW NIBBLE; the # high nibble appears to carry other state we haven't decoded # yet. Documented in BMR #id-291.