[ In-Vehicle Event Handlers ]

Six scripts on this site react to vehicle state changes by watching the CAN bus. They watch different signals (ignition state, engine state, key fob events, lighting, turn signals) on different buses for different reasons, but architecturally they're nearly identical: candump pipe → state machine → action handler. This page is that architecture, with the script listing in Scripts implementing this pattern immediately below for quick reference.

[ Scripts implementing this pattern ]

Six scripts on this site react to vehicle state changes via the shared candump → state-machine → action pattern documented in the sections below. They live here so you can browse the pattern and the implementations on the same page; the rest of the page explains the architecture they all share.

Ignition-state event monitor for a Raspberry Pi in-vehicle setup. Watches
$122 on CAN-IHS for OFF→RUN and RUN→OFF transitions, starts/stops a
configurable CAN recorder accordingly, shifts the CPU governor between
powersave (idle) and ondemand (running), and optionally fires a remote-start
HVAC trigger on the first observed OFF→RUN edge so a script restart
on an already-running engine doesn't ghost-fire HVAC.

$122's bit-26 (0x04000000) is the run-state flag — see
Bus & Message Reference
#id-122 for the full description and sleep-state semantics. All paths
(recorder, HVAC, CPU sysfs node) are overridable at the top of the script
for non-Pi targets. SIGINT trap stops the recorder + restores idle governor
before exit; outer reconnect loop survives the candump pipe dying when the
vehicle goes deep-sleep.

Repurposing this for ESS-off, dashcam start, or any other RUN-edge
automation is a one-function change — swap the body of
on_run_start / on_run_stop for your action.

CLI: --once (test single transition), --no-cpu (skip governor on non-Pi
hosts), --debug off|on|raw, --verbose, --help.

If you want the same dump-recorder lifecycle PLUS CPU-governor +
autocollect + autohvac orchestration in one process, see
monitor.sh below — it's a
superset of this script. Don't run both at once; they'd both spawn
competing recorders.
monitor.sh — 05.2026
Top-level in-vehicle orchestrator. Watches $122 on CAN-IHS like
Blackbox_monitor.sh above
but covers a wider scope — it is the in-vehicle service manager
for jmccorm's Pi setup.

On ignition-on transition:
    — CPU governor → ondemand
    — spawn autocollect.sh
    — spawn dump any (black-box CAN recorder)
    — spawn autohvac (remote-start HVAC automation;
       exits on its own if the engine wasn't remote-started)

On ignition-off transition:
    — kill the dump recorder
    — CPU governor → powersave

Don't run alongside Blackbox_monitor.sh. Both gate the
dump recorder on $122 transitions; running both means two recorders
fighting for the same log path. Pick one:
    Blackbox_monitor.sh   minimal — only the recorder lifecycle
    monitor.sh            superset — recorder + governor + autocollect
                           + autohvac in a single supervisor

Threshold-comparison portability fix in this polish: jmccorm's original
2022 code used printf %d 0x$IGNITION on the full 16-hex-char
payload, which depended on 32-bit bash arithmetic overflowing the value
on the original Pi 3. On modern 64-bit Pi 4 / 5 the same code returns the
full 64-bit integer and the run-state threshold comparison would always
be true, falsely reporting "engine running" on every frame. The polished
version explicitly extracts the top 32 bits before the threshold check,
preserving original semantics on any Linux host.

Original by jmccorm (01.2022). Polish by magikh0e: 32-bit-vs-64-bit
portability fix (above), [ ... ]] syntax error fixed,
IGNITIOIN typo fixed, variables consistently quoted,
header documents what other scripts this orchestrator launches.
autocollect.sh — 05.2026
Engine-state event framework. Sibling to Blackbox_monitor.sh above but
more general: watches $077 on CAN-C (instead of $122 on
CAN-IHS) and exposes six user-customizable hook functions:

    vehiclepoweredon   vehicle on (TIP / remote start)
    enginestarted      engine running, observation stable
    oneminute          once per minute while running
    tenminute          once per ten minutes while running
    onehour            once per hour while running
    engineshutdown     engine off, last chance to flush state

Each hook is a no-op by default; populate with your own work (start a
logger, take a GPS fix, sync the SD card, send a heartbeat, etc) and
the framework handles all the timing — rate-limited to once per
second, edge-triggered for state transitions, modulo-based scheduling
for the periodic hooks.

$077 state encoding (observed on the JEEP platform this script was
developed against):

    0x0422   Engine running (most common code)
    0x4421   Engine running (alternate / mode-dependent)
    0x5D21   Remote start in progress
    < 0x0400 Vehicle off / accessory / pre-crank

Use Blackbox_monitor.sh for "just start/stop a dashcam recorder"; use
autocollect.sh for "I want event hooks at multiple rates." They can
run alongside each other since they watch different IDs on different
buses.

Original by jmccorm (the hook framework + multi-rate dispatch design).
Polish by magikh0e fixed a real bug in the legacy version — the
original's -gt / -lt comparisons against hex
strings crashed on any state with non-decimal hex digits, requiring
hard-coded per-state workarounds; the new version converts via
$((16#$state)) so all hex states work. Legacy preserved
as autocollect.legacy.txt.

CLI: --once, --debug off|on|raw, --verbose, --help.
Remote_WiFi.sh — 05.2026
Toggles the Raspberry Pi's WiFi radio from the JEEP key fob via $1C0
(Remote Lock / Unlock) on CAN-IHS. Two operating modes selectable via
--mode:

  --mode auto (default):
    Lock                →  ip link set wlan0 down  (radio off, secure)
    2nd Unlock          →  ip link set wlan0 up    (radio on)
    1st Unlock / Idle   →  no-op

  --mode toggle:
    Lock                →  no-op (WiFi state survives the lock)
    2nd Unlock          →  flip WiFi state (alternates each press)
    1st Unlock / Idle   →  no-op

Both modes are from jmccorm's original 2022 work — they were two
coexisting variants of the same idea, now packaged behind one
--mode switch. The "double-tap unlock" is the trigger in
both. In auto mode it restores WiFi that the lock disabled. In toggle
mode it's a single-button latch — useful when you don't want a
"lock and walk away" to kill your SSH session, or when you want
explicit manual control over when the radio is broadcasting.

Observed payloads (Lock = 0x21, 1st Unlock = 0x23, 2nd Unlock = 0x24,
Idle = 0x00 in byte 0; 0x90 active vs 0x80 idle in byte 3) are documented
at Bus & Message Reference
#id-1c0. Unknown payloads are logged so a session on a different FCA
platform surfaces candidates for porting.

Modernized to use iproute2 (ip link) instead of deprecated
ifconfig/iwconfig. SIGINT trap deliberately does
NOT restore WiFi on exit — if you last locked the car (auto mode)
or had WiFi disabled (toggle mode), the radio should stay down.

CLI: --mode auto|toggle, --once, --dry-run (log events without touching
WiFi), --debug, --verbose, --help.
backlight.sh — 05.2026
Mirrors the JEEP's dashboard dimmer onto the Raspberry Pi LCD backlight,
in real time. Watches $291 on CAN-IHS (interior dimmer
+ headlight state) and writes
/sys/class/backlight/rpi_backlight/brightness so the Pi's
7" Touch Display goes dark with the rest of the dash at night and
stays bright during the day.

Behaviour:

    Running lights OFF  →  Pi backlight at $FULL_BRIGHTNESS (daytime)
    Running lights ON   →  Pi backlight scaled from the dimmer
                                 byte (linear, with sane clamp on the
                                 dim end so the screen never goes
                                 fully black)

Uses the $291 byte map documented at
Bus & Message Reference
#id-291 — byte 6 for the dimmer value, low nibble of byte 2
for the running-lights flag. SIGINT trap restores full brightness on
exit so a crashed script never leaves the screen stuck dim. Reconnect-
on-pipe-death loop survives the bus going quiet during sleep.

Original by redracer; legacy preserved as
backlight.legacy.txt.
Polish (bash-arith instead of bc, named tunables,
SIGINT cleanup, pre-flight sysfs check) by magikh0e.

CLI: --once, --verbose, --help.
lights.sh — 05.2026
Exposes the vehicle's turn-signal state as marker files in
/run/vehicle/lights/. Watches $291 and decodes the
high nibble of byte 3:

    no blinker            →  all blinker-* files removed
    left  blinker         →  $VEHICLE/blinker-left
    right blinker         →  $VEHICLE/blinker-right
    hazards (both)        →  $VEHICLE/blinker-hazard

Pattern: file-existence-as-flag. Any process that wants to know
whether the left turn signal is currently on can test -e
/run/vehicle/lights/blinker-left — zero IPC machinery,
zero dependencies. The price is that $VEHICLE needs to
live on tmpfs (or similar memory-backed FS) so repeated touch / rm
don't wear out the SD card.

$291 byte 3 high-nibble decode is documented at
Bus & Message Reference
#id-291 alongside the other lighting fields (running lights,
cabin lights, dimmer, headlights). $291 is body-control state —
natively broadcast on CAN-IHS, also re-broadcast onto CAN-C by the
TIPM/CGW gateway, so either bus works.

Original by jmccorm (01.2022). Polish by magikh0e fixed two real
bugs: (1) the original re-touched the same file on every frame
(~10 Hz cadence), wasted filesystem work even on tmpfs — added
an edge filter on the blinker nibble; (2) the original
touch blinker-right didn't first remove
blinker-left, so a left-to-right transition could
leave both files present — the polished case branches clear all
blinker-* first. Also added a SIGINT trap (pattern A explicit
reset — removes all marker files on exit so consumers don't see
a stale state frozen) and an outer reconnect loop.

CLI: none yet (DEBUG=true|false|raw at the top of the script).

[ The shared pattern ]

Every event handler on this site is some variation of:

while true; do
    candump -L "$IFACE,$ID:$MASK" | while read TIME BUS FRAME; do
        payload="${FRAME##*#}"
        # extract state from payload bytes
        state=...

        # rate-limit if needed
        [[ "$NOW" -eq "$LAST_TICK" ]] && continue

        # edge filter
        [[ "$state" == "$last_state" ]] && continue

        # dispatch
        case "$state" in
            ...) on_event_a ;;
            ...) on_event_b ;;
        esac

        last_state="$state"
    done

    # candump pipe died (CAN went silent, interface dropped, etc).
    # back off and reconnect.
    sleep "$RETRY_DELAY"
done

The five parts: (1) outer reconnect loop, (2) candump pipe with kernel-side ID filter, (3) per-frame state extraction, (4) rate-limit + edge filter, (5) dispatch to an action handler. Each script makes slightly different choices in parts 3-5; parts 1 and 2 are essentially identical.

[ Edge vs level triggering ]

CAN broadcast messages repeat at fixed rates. A state-bearing message ($122 ignition, $1C0 fob, $291 lighting, $077 engine state) re-broadcasts on every cycle whether or not the value has changed. Implementations have two choices:

LEVEL TRIGGERING. Act on every observed frame regardless of whether it differs from the last one. Simple but wrong for most use cases — you end up firing your action 10-100 times per second.

EDGE TRIGGERING. Cache the last-seen value. Act only when this frame's payload differs from the cached one. Update the cache after every action. Result: one action per real-world state change, not per CAN frame. Implementation cost: one variable, one comparison, one assign:

if [[ "$state" == "$last_state" ]]; then continue; fi
# ... action ...
last_state="$state"

Every event-handler script on the site uses edge triggering. The exception that proves the rule: pyJeepCan.py renders fields on every frame because the goal there is a live display, not event reaction — level-triggering is correct for live displays, edge-triggering is correct for event reactors.

The edge cache also serves as duplicate suppression for messages that come in bursts. Remote_WiFi's $1C0 broadcasts the same payload many times during a single fob button press; without the edge cache, one button press would be many fired actions.

[ Rate-limiting strategies ]

Edge triggering handles the "no spurious actions per state" problem, but doesn't help if the underlying state itself oscillates faster than you want to react. Three strategies in use:

1. None. Pure edge detection is sufficient. Used by Remote_WiFi.sh (fob events are sparse — seconds between button presses; edge alone is enough) and Blackbox_monitor.sh (ignition transitions are rare; same reason).

2. Timestamp gate — "process at most once per second":

NOW=$SECONDS
[[ "$NOW" -eq "$LAST_TICK" ]] && continue
LAST_TICK=$NOW
# ... rest of loop body ...

Used by backlight.sh ($291 broadcasts every ~100ms but the dashboard dimmer doesn't change that fast; 1 Hz processing throws away 9 of every 10 frames without losing fidelity).

3. Modulo dispatch — "fire callback X every N seconds during sustained state":

(( SECONDS % 60 == 45 ))    && oneminute &
(( SECONDS % 600 == 599 ))  && tenminute &
(( SECONDS % 36000 == 35970 )) && onehour &

Used by autocollect.sh for its 1-minute / 10-minute / 1-hour periodic hooks while the engine is running. The modulo offsets are arbitrary — pick non-overlapping seconds-of-day to keep the hooks from all firing on the same second.

Strategies 2 and 3 compose: timestamp-gate the loop body to 1 Hz, then use modulo within that for multi-rate dispatch. autocollect does exactly this.

[ Cleanup disciplines ]

An event handler runs forever — the only way it ends is by external signal (Ctrl+C, systemd stop, SIGKILL, host shutdown) or by internal exit (engine off triggers `engineshutdown` hook, which calls `exit 0`). What happens to the side effects the script has caused depends on which cleanup discipline you picked. Three patterns:

PATTERN A — Explicit reset. On exit, undo whatever state the script established. Stop external processes, restore system settings. Used by Blackbox_monitor.sh (stops the recorder, restores idle governor on exit) and autocollect.sh (same — recorder lifecycle tied to script lifecycle). Pick this when the script's state has external consequences that would be bad if left running (a black-box recorder eating disk; a CPU governor stuck in ondemand draining battery; a process holding a hardware resource).

PATTERN B — Deliberate non-reset. On exit, do NOT undo the established state. The script's last action IS the intended final state; reverting it would be wrong. Used by Remote_WiFi.sh (does NOT bring WiFi back up on exit — the script's whole purpose is "WiFi off when locked"; restoring it would defeat that purpose). Pick this when the script's exit-state has security or privacy meaning that should survive a script crash. If a user locked the car and the script died, the radio should stay off; the user can manually re-enable if they want.

PATTERN C — Restore-to-safe. On exit, return to a known-safe default state regardless of what the script was doing when it died. Used by backlight.sh (restores backlight to FULL brightness on exit — a crashed script that left the screen stuck dim would be worse than a crashed script that left the screen too bright). Pick this when the script's intermediate states are usable only if the script keeps running. A user staring at a frozen too-dim screen is worse than a user staring at a working too-bright screen.

All three use the same SIGINT trap pattern:

CLEANED=0
cleanup() {
    [[ "$CLEANED" -eq 1 ]] && return 0   # idempotent
    CLEANED=1
    # pattern-specific reset / non-reset / restore
    exit 0
}
trap cleanup INT TERM

Only the BODY of cleanup() differs across patterns.

[ Hardware lifecycle integration ]

An event handler running on a Pi inside a vehicle has to deal with the vehicle's power and bus lifecycle, which doesn't match a normal desktop computer's. Three failure modes worth designing around:

1. Crank brown-out. During engine start, vehicle 12V drops to ~6-8V for ~200ms as the starter motor pulls hundreds of amps. Direct USB-powered Pis brown out and corrupt their SD card if a write was in flight. The dev-stack page covers the Zero2Go Omni mitigation (supercaps for ride-through). Software side: nothing you can do; this is a hardware problem.

2. Vehicle sleep. 10+ minutes after ignition off with doors closed, the BCM enters a low-power state. Cabin 12V may drop or be cut. The CAN bus stops carrying any traffic at all — your candump pipe receives nothing. Without the outer reconnect loop, the script silently exits and doesn't come back when the vehicle wakes.

3. SD card flush on power loss. Vehicle power can disappear unpredictably (battery disconnect for repair, blown fuse, alternator failure). If the script has file I/O in flight when power cuts, the SD card may corrupt. autocollect's engineshutdown hook is your last clean chance:

engineshutdown() {
    stop_recorder
    sync; sync; sleep 5; sync; sync
}

The Zero2Go Omni gives you ~30 seconds of clean-shutdown time between detecting vehicle sleep and actually cutting power — enough to flush, but only if your script's shutdown hook actually does the flushing.

For the matching hardware setup, see the Example CANBus dev stack page — specifically the why-Zero2Go section.

[ Bus-quiet behaviour ]

Vehicles spend more time asleep than awake. An event handler that runs as a systemd service needs to survive long idle periods. The patterns:

Outer reconnect loop with backoff:

while true; do
    candump -L "$IFACE,$ID:$MASK" 2>/dev/null \
    | while read -r TIME BUS FRAME; do
        # ... per-frame processing ...
    done

    # if we reach here, the candump pipe ended -- back off
    # and reconnect.
    echo "[reconnect] candump pipe ended; retrying in ${DELAY}s"
    sleep "$DELAY"
done

What can end the pipe: CAN interface dropped (cable unplugged, adapter reset), socketCAN backend error, kernel module reload, or the vehicle being truly asleep for long enough that the bus is considered "dead" by some socketCAN implementations.

NM wake doesn't help here — those frames wake the ECUs, not your candump. Just back off and reconnect; when the vehicle wakes, the next candump invocation will start receiving frames again.

Backoff value: 5 seconds is conservative-but-fast. Long enough that you're not hammering the kernel during persistent failure, short enough that you reconnect within seconds of the vehicle waking. All four event-handler scripts use 5s.

[ Adding your own ]

Worked example: light up a Pi-side LED when high beams come on, turn it off when they go off.

Step 1 — Identify the signal. From BMR #id-291: $291 byte 7 is the headlights state, with 0x00=off, 0x02=low beam, 0x04=high beam. CAN-IHS.

Step 2 — Edge or level? Edge. We only want the LED to react on transition; flashing it on every $291 broadcast during sustained high-beam state would just be a 10 Hz strobe.

Step 3 — Rate-limit? No additional limit needed. $291 broadcasts at ~100 ms, but the edge filter reduces that to actual transitions only. High-beam transitions happen at most a few times per minute even on a stalk-happy driver.

Step 4 — Cleanup discipline? Pattern C (restore-to-safe). On exit, turn the LED off. The LED is a status indicator with no safety meaning; leaving it stuck on after the script dies would just be wrong-information.

Step 5 — Reconnect? Yes, wrap the candump pipe in a 5-second-backoff outer loop. Vehicle sleep is a real event we need to survive.

Step 6 — Pre-flight. Check CAN-IHS is up, check the LED GPIO is configured. Bail out with a helpful error message if either is missing.

Skeleton bash (about 50 lines including comments):

#!/bin/bash
#
# highbeam_led.sh -- light up GPIO LED when high beams come on.

set -eu

CAN_IHS=can0
CAN_ID=291
LED_GPIO=/sys/class/gpio/gpio17/value
RETRY_DELAY=5

last_state=""
CLEANED=0

cleanup() {
    [[ "$CLEANED" -eq 1 ]] && return 0
    CLEANED=1
    # Pattern C: restore-to-safe (LED off)
    echo 0 > "$LED_GPIO" 2>/dev/null || true
    exit 0
}
trap cleanup INT TERM

# Pre-flight
if ! ip link show "$CAN_IHS" >/dev/null 2>&1; then
    echo "ERROR: $CAN_IHS not found" >&2
    exit 1
fi
if [[ ! -w "$LED_GPIO" ]]; then
    echo "ERROR: $LED_GPIO not writable (configure GPIO17 as output first)" >&2
    exit 1
fi

# Main: outer reconnect loop
while true; do
    candump -L "$CAN_IHS,0${CAN_ID}:0FFF" 2>/dev/null \
    | while read -r TIME BUS FRAME; do
        payload="${FRAME##*#}"
        [[ ${#payload} -lt 16 ]] && continue

        # Byte 7 = headlight state (see BMR #id-291)
        headlight="${payload:14:2}"

        # Edge filter
        [[ "$headlight" == "$last_state" ]] && continue
        last_state="$headlight"

        # Dispatch
        if [[ "$headlight" == "04" ]]; then
            echo 1 > "$LED_GPIO"   # high beam on
        else
            echo 0 > "$LED_GPIO"   # any other state
        fi
    done

    echo "[reconnect] candump pipe ended; retrying in ${RETRY_DELAY}s" >&2
    sleep "$RETRY_DELAY"
done

That's the whole pattern. Substitute your signal's message ID, byte offset, and dispatch logic; pick the right cleanup discipline from above; the rest is mechanical.

[ See also ]