# GCU CAN Bus Specification

> **Audience:** AIM dash configuration, DBC file maintenance, external integrators.
> This is the wire-level protocol reference. For firmware internals (parsing, safety logic, error recovery code), see [CAN_INTEGRATION.md](CAN_INTEGRATION.md).

**Hardware:** Adafruit Feather M4 CAN Express (SAMD51 + MCAN)
**Bus speed:** 500 kbit/s
**Byte order:** Big-endian (all messages, inbound and outbound)

---

## Outbound Messages (GCU → Bus)

### 0x700 — Heartbeat

| Field            | Bytes | Type      | Unit    | Notes                |
| ---------------- | ----- | --------- | ------- | -------------------- |
| Firmware version | 0–3   | uint32 BE | —       | "3.0.5" → 0x00007535 |
| Uptime           | 4–7   | uint32 BE | seconds | Wraps at ~136 years  |

**Interval:** 1000 ms

---

### 0x701 — Input / Pressure Telemetry

| Field               | Bytes | Type      | Unit | Notes                                                  |
| ------------------- | ----- | --------- | ---- | ------------------------------------------------------ |
| State               | 0     | bitfield  | —    | See below                                              |
| Tank pressure       | 1–2   | uint16 BE | PSI  | 0–150                                                  |
| GSS command voltage | 3–4   | uint16 BE | mV   | 500 = up, 2500 = neutral, 4500 = down                  |
| Shift mode          | 5     | bitfield  | —    | See below                                              |
| Dashboard status    | 6     | bitfield  | —    | See below                                              |
| CAN bus health      | 7     | nibbles   | —    | Upper = Bus-Off count, Lower = reset count (0–15 each) |

**State byte (byte 0) bit layout:**

| Bit | Field            | Values      |
| --- | ---------------- | ----------- |
| 0   | Upshift paddle   | 1 = pressed |
| 1   | Downshift paddle | 1 = pressed |
| 2   | Neutral button   | 1 = pressed |
| 3   | Compressor relay | 1 = on      |
| 7:4 | Reserved         | 0           |

Paddle bits stay set for at least 200 ms after a press is dispatched
(`TELEMETRY_PADDLE_LATCH_MS` — two frames at 10 Hz). Without the latch a
safety-blocked shift resets the paddle state machine within one loop tick
and the press never appeared on this channel.

**Shift mode byte (byte 5) bit layout:**

| Bit | Field               | Values                                                 |
| --- | ------------------- | ------------------------------------------------------ |
| 0   | GP sensor active    | 1 = GP sensor is gear source (closed-loop capable)     |
| 1   | CAN gear active     | 1 = CAN is gear source (gear-aware mode)               |
| 2   | Fallback mode       | 1 = no gear data, safety gates disabled                |
| 3   | Neutral button mode | 1 = car (N-below-1st), 0 = moto (NT button for N)      |
| 4   | Closed-loop active  | 1 = GP sensor driving valve timing during active shift |
| 7:5 | Reserved            | 0                                                      |

Note: Bits 0-2 are mutually exclusive (exactly one gear source is active at a time).

**Dashboard status byte (byte 6) bit layout:**

| Bit | Field          | Values                           |
| --- | -------------- | -------------------------------- |
| 0   | Pressure high  | 1 = tank pressure critical       |
| 1   | CAN data error | 1 = CAN data stale/invalid       |
| 2   | Low RPM        | 1 = shift blocked, RPM too low   |
| 3   | Top gear       | 1 = shift blocked, already 6th   |
| 4   | Low gear       | 1 = shift blocked, already 1st/N |
| 5   | Money shift    | 1 = shift blocked, over-rev risk |
| 6   | Shift error    | 1 = unknown shift error          |
| 7   | CAN error      | 1 = CAN bus error                |

0x00 = all clear. Set on-demand when a warning occurs, broadcast at next telemetry tick.

**Interval:** 100 ms

---

### 0x702 — Shift Statistics

| Field                   | Bytes | Type      | Unit  |
| ----------------------- | ----- | --------- | ----- |
| Upshift success count   | 0–1   | uint16 BE | count |
| Upshift fail count      | 2     | uint8     | count |
| Downshift success count | 3–4   | uint16 BE | count |
| Downshift fail count    | 5     | uint8     | count |
| Reserved                | 6–7   | —         | —     |

Fail counts are uint8 and saturate at 255 (they reset every boot and rarely
climb past a handful in a session). Fail counts mean "attempted but didn't
complete" (closed-loop timeout, exhausted misshift retries). Safety-blocked
shifts are NOT counted here —
no valve ever fired; they show up in the 0x703 block-count nibbles and as a
0x703 blocked-result frame instead.

> **This frame is not uniformly 16-bit.** Bytes 2 and 5 are single-byte fail
> counts interleaved with the 16-bit success counts, each one directly after the
> success count it pairs with, so the fields do not start on even byte
> boundaries. Use the start bits and widths in the channel table below
> (8/16, 16/8, 32/16, 40/8) rather than assuming four evenly spaced uint16s.
> 0x703 has the same shape: neutral shift success is a uint8 at byte 4 followed
> by a reserved byte.
>
> Verified on the car 2026-07-25: the RS3 config matches this table, and matches
> the firmware. If a counter still reads wrong on the dash, the channel widths are
> not the place to look. Cross-check against `DUMP_STATUS`'s `SHIFT_COUNTS` line,
> which reports the same six counters straight off the firmware with no channel
> config in the path, and compare that against what the dash shows.

**Interval:** 1000 ms

---

### 0x703 — Shift Result (event-driven + 5 s rebroadcast)

Sent once immediately after each shift outcome — completed OR refused by a
safety gate (money shift, low/top gear, invalid data). A refused shift sends
duration 0, the blocked/invalid-data result bit, and start gear == end gear.

The last payload is also rebroadcast every 5 s so dash channels that expire
on quiet data (AiM shows "-") stay alive between shifts. Until the first
shift after boot, the rebroadcast carries an all-zero result byte ("no shift
yet"), duration 0, and gears 0/0.

| Field                 | Bytes | Type      | Unit  | Notes                                                                          |
| --------------------- | ----- | --------- | ----- | ------------------------------------------------------------------------------ |
| Shift duration        | 0–1   | uint16 BE | ms    | Valve-open to valve-close                                                      |
| Result code           | 2     | bitfield  | —     | See below                                                                      |
| Gears                 | 3     | nibbles   | gear  | High nibble = start gear, low nibble = end gear (0=N, 1–6)                     |
| Neutral shift success | 4     | uint8     | count | Cumulative 1st→N shifts (saturate at 255)                                      |
| Reserved              | 5     | —         | —     |                                                                                |
| Block counts          | 6     | nibbles   | count | Upper nibble = money shift blocks, lower = invalid CAN blocks (saturate at 15) |
| Fallback shift count  | 7     | uint8     | count | Shifts completed with safety disabled (saturate at 255)                        |

**Result code byte (byte 2) bit layout:**

| Bit | Field        | Values                         |
| --- | ------------ | ------------------------------ |
| 0   | Success      | 1 = gear change confirmed      |
| 1   | Timeout      | 1 = timed out waiting for gear |
| 2   | Blocked      | 1 = blocked by safety gate     |
| 3   | Invalid data | 1 = CAN data was stale/invalid |
| 7:4 | Reserved     | 0                              |

Note: Exactly one bit is set per shift result (one-hot encoding). The
boot-time rebroadcast (before any shift has happened) is the one exception:
all bits 0 = no shift yet.

**Trigger:** End of shift sequence, safety-gate refusal, or 5 s rebroadcast timer

---

### 0x704 — Brake Light Status (50 Hz)

Published only when `brake_front_source` = 1 (`analog(A4)`), which is the default.
It is **not** gated on `brake_light_enable`. The sensor is physically off the dash the
moment the harness changes, which has nothing to do with whether lamp control is switched
on, so a car with the lamp still disabled keeps logging front pressure with the enable flag
clear.

| Field         | Bytes | Type      | Notes                                                     |
| ------------- | ----- | --------- | --------------------------------------------------------- |
| Reserved      | 0     | uint8     | Always 0                                                  |
| Front psi     | 1–2   | int16 BE  | `BRAKE_SENTINEL` (0x8000) when excluded or not the source |
| Rear psi      | 3–4   | int16 BE  | Mirrored from 0x711; sentinel when stale or excluded      |
| Flags         | 5     | bitfield  | The authoritative status; see the table below             |
| Front raw ADC | 6–7   | uint16 BE | 0–1023 counts, so a car can be calibrated off a dash log  |

| Bit | Meaning                      |
| --- | ---------------------------- |
| 0   | front channel excluded       |
| 1   | rear channel excluded        |
| 2   | 0x711 stale                  |
| 3   | lamp physically on           |
| 4   | `BRAKE_TEST` override active |
| 5   | startup warning active       |
| 6   | `brake_light_enable`         |
| 7   | reserved, 0                  |

**The flags byte is the whole status on this frame.** The firmware also keeps a collapsed
status code (0 off, 1 on, 2 front failed, 3 both failed, 4 startup warning), but it is
strictly derivable from the flags — bit 0 clear means code 0 or 1 and bit 3 says which,
bit 0 set means code 2 or 3 and bit 1 or bit 2 says which, bit 5 set means code 4. The
reverse does not hold: under code 2 the rear channel drives the lamp, and only bit 3 tells
you whether it is lit. Publishing the code as well would have been a weaker second copy of
byte 5, so byte 0 is reserved instead. The code is still printed in `DUMP_STATUS`, where
one legible number helps a human reading a serial line. A dash alarms on "bit 0 == 1" the
same way it would have on "code >= 2" — one channel, one condition.

A startup warning blast does not hide the fault it is warning about: flags bit 0 stays set
throughout, `DUMP_STATUS` reads `excl=front src=rear`, and a `BRAKE_SENSOR_FAULT` record
hits flash at the moment of latch.

**Rear freshness on this frame follows the lamp's policy, not the AiM channel timeout.**
Bit 2 and the rear-psi sentinel both key off `BRAKE_LIGHT_STALE_MS` (200 ms), not
`AIM_INPUT_TIMEOUT_MS` (5 s). Past 200 ms the brake-light module has already discarded the
rear channel, so reporting it fresh would leave this frame disagreeing with the lamp for up
to 4.8 seconds.

**Bus cost is about 1.25%.** An 8-byte standard frame occupies ~0.25 ms at 500 kbit/s once
framing and stuffing are counted (`docs/CAN_NONBLOCKING_TX_DESIGN.md`), so 50 Hz is
~12.5 ms of bus time per second. That rate matches what 0x711 used to carry front pressure
at, so logged brake-trace fidelity is unchanged by the move.

**Trigger:** 50 Hz inside `sendTelemetry()`, so it inherits the existing
`canTelemetrySendAllowed()` TEC gate and sheds with the rest of telemetry on a degraded bus.

---

## AIM Configuration Reference

Byte order is **High to Low (Big Endian or Motorola)** for every channel. Data format is **Unsigned integer** for the channels in the table below, with these exceptions, all **Signed integer**:

- Front and rear brake pressure on `0x704` (outbound) and on `0x711` (inbound). Configured unsigned, a resting pedal reads a small negative value that decodes as roughly 65,500 psi, and the `0x8000` no-data marker becomes 32,768 psi rather than an obvious sentinel.
- Lateral and longitudinal G on `0x712` (inbound), where the sign carries the direction.

Check the per-frame tables above and below for the authoritative type of any field; this rule is a summary, not a substitute.

> **AIM start bit convention (Motorola):** The start bit is NOT the MSB or the first byte — it's the bit number of the **LSB of the last byte** the signal occupies. For a uint16 in bytes 1-2, the start bit is 16 (byte 2 × 8), not 8. For a uint32 in bytes 0-3, the start bit is 24 (byte 3 × 8), not 0. This is counterintuitive but matches what AIM's "High to Low" byte order expects. When in doubt, enter the value and check the yellow highlight in the bit grid — it should cover the correct bytes.

| Channel             | CAN ID | Start Bit | Bits | Gain  | Offset | Unit | Notes                                                          |
| ------------------- | ------ | --------- | ---- | ----- | ------ | ---- | -------------------------------------------------------------- |
| Firmware version    | 0x700  | 24        | 32   | 1.0   | 0.0    | #    | 30005 = v3.0.5                                                 |
| Uptime              | 0x700  | 56        | 32   | 1.0   | 0.0    | sec  |                                                                |
| Tank pressure       | 0x701  | 16        | 16   | 1.0   | 0.0    | PSI  | 0–150                                                          |
| GSS voltage         | 0x701  | 32        | 16   | 0.001 | 0.0    | V    | 0.5 = cut, 2.5 = nom, 4.5 = blip                               |
| Upshift success     | 0x702  | 8         | 16   | 1.0   | 0.0    | #    |                                                                |
| Upshift fail        | 0x702  | 16        | 8    | 1.0   | 0.0    | #    | Byte 2                                                         |
| Downshift success   | 0x702  | 32        | 16   | 1.0   | 0.0    | #    | Bytes 3–4                                                      |
| Downshift fail      | 0x702  | 40        | 8    | 1.0   | 0.0    | #    | Byte 5                                                         |
| Shift duration      | 0x703  | 8         | 16   | 1.0   | 0.0    | ms   |                                                                |
| Start gear          | 0x703  | 28        | 4    | 1.0   | 0.0    | gear | High nibble of byte 3 (0=N, 1–6)                               |
| End gear            | 0x703  | 24        | 4    | 1.0   | 0.0    | gear | Low nibble of byte 3 (0=N, 1–6)                                |
| Neutral shift count | 0x703  | 32        | 8    | 1.0   | 0.0    | #    | Byte 4                                                         |
| Money shift blocks  | 0x703  | 52        | 4    | 1.0   | 0.0    | #    | High nibble of byte 6 (max 15)                                 |
| Invalid CAN blocks  | 0x703  | 48        | 4    | 1.0   | 0.0    | #    | Low nibble of byte 6 (max 15)                                  |
| Fallback shifts     | 0x703  | 56        | 8    | 1.0   | 0.0    | #    | Shifts with safety off (max 255)                               |
| Front brake psi     | 0x704  | 16        | 16   | 1.0   | 0.0    | PSI  | **Signed**; 0x8000 = no data. Replaces the 0x711 front channel |
| Rear brake psi      | 0x704  | 32        | 16   | 1.0   | 0.0    | PSI  | **Signed**; 0x8000 = no data. Mirrored from 0x711              |
| Brake flags         | 0x704  | 40        | 8    | 1.0   | 0.0    | #    | Byte 5 bitfield; see the 0x704 section                         |
| Front brake ADC     | 0x704  | 56        | 16   | 1.0   | 0.0    | #    | Raw A4 counts 0–1023, for bench calibration                    |

Bitfield channels (configure as **Status Code** type in AIM, no gain/offset):

| Channel             | CAN ID | Start Bit | Bits | Description                      |
| ------------------- | ------ | --------- | ---- | -------------------------------- |
| Upshift paddle      | 0x701  | 0         | 1    | 1 = pressed                      |
| Downshift paddle    | 0x701  | 1         | 1    | 1 = pressed                      |
| Neutral button      | 0x701  | 2         | 1    | 1 = pressed                      |
| Compressor          | 0x701  | 3         | 1    | 1 = on                           |
| GP sensor active    | 0x701  | 40        | 1    | 1 = closed-loop capable          |
| CAN gear active     | 0x701  | 41        | 1    | 1 = gear-aware mode              |
| Fallback mode       | 0x701  | 42        | 1    | 1 = safety disabled              |
| Neutral button mode | 0x701  | 43        | 1    | 1 = car (N-below-1st), 0 = moto  |
| Closed-loop active  | 0x701  | 44        | 1    | 1 = GP driving valve timing      |
| Shift success       | 0x703  | 16        | 1    | 1 = gear change confirmed        |
| Shift timeout       | 0x703  | 17        | 1    | 1 = timed out waiting for gear   |
| Shift blocked       | 0x703  | 18        | 1    | 1 = blocked by safety gate       |
| Shift invalid data  | 0x703  | 19        | 1    | 1 = CAN data was stale/invalid   |
| Pressure high       | 0x701  | 48        | 1    | 1 = tank pressure critical       |
| CAN data error      | 0x701  | 49        | 1    | 1 = CAN data stale/invalid       |
| Low RPM             | 0x701  | 50        | 1    | 1 = shift blocked, RPM too low   |
| Top gear            | 0x701  | 51        | 1    | 1 = shift blocked, already 6th   |
| Low gear            | 0x701  | 52        | 1    | 1 = shift blocked, already 1st/N |
| Money shift         | 0x701  | 53        | 1    | 1 = shift blocked, over-rev risk |
| Shift error         | 0x701  | 54        | 1    | 1 = unknown shift error          |
| CAN error           | 0x701  | 55        | 1    | 1 = CAN bus error                |
| Bus-Off count       | 0x701  | 60        | 4    | EMI Bus-Off events (0–15)        |
| CAN reset count     | 0x701  | 56        | 4    | CAN bus resets completed (0–15)  |
| Front brake failed  | 0x704  | 40        | 1    | 1 = front channel excluded       |
| Rear brake failed   | 0x704  | 41        | 1    | 1 = rear channel excluded        |
| Brake 0x711 stale   | 0x704  | 42        | 1    | 1 = no 0x711 for 200 ms          |
| Brake lamp on       | 0x704  | 43        | 1    | 1 = lamp physically lit          |
| Brake test override | 0x704  | 44        | 1    | 1 = `BRAKE_TEST` holding the pin |
| Brake startup warn  | 0x704  | 45        | 1    | 1 = startup warning blast active |
| Brake light enabled | 0x704  | 46        | 1    | 1 = `brake_light_enable` (default on) |

Alarm on **Front brake failed** for a dead front sensor, and on it plus **Rear brake
failed** together for the fail-bright case. That pair is what the old `Brake status code`
channel expressed as `>= 2` and `== 3`.

---

## Inbound Messages (ECU → GCU)

Single ECU type supported: Suzuki GSX-S 1000 — bit-packed on CAN ID 0x1A1.

| Signal | CAN ID | Byte(s)     | Encoding                                     |
| ------ | ------ | ----------- | -------------------------------------------- |
| RPM    | 0x1A1  | 0–1         | Big-endian uint16, scale 1.0                 |
| Gear   | 0x1A1  | 3, bits 2:0 | 3-bit unsigned, 0=N / 1–6 (on-car validated) |

Single message carries both RPM and gear.

---

## Inbound Messages (AiM dash → GCU)

The AiM master re-broadcasts a few channels onto the car bus via RS3
"CAN Output" payloads (rate per frame below — `0x710` 1 Hz, `0x711` 50 Hz,
`0x712` 10 Hz). **The firmware layout below is the source of truth — configure
RS3 to match it** (all fields big-endian / "HL"). The firmware is
rate-agnostic — it stores the latest value of each frame as it arrives — so
the broadcast rate is purely an RS3 choice driven by how fast each channel
actually changes.

### 0x710 — AiM Master (1 Hz)

| Field      | Bytes | Type      | Notes                                                                                                                                                                       |
| ---------- | ----- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| UNIX time  | 0–3   | uint32 BE | GPS-disciplined epoch seconds (UTC)                                                                                                                                         |
| GPS speed  | 4–5   | uint16 BE | Raw as configured in RS3 (scaling TBD)                                                                                                                                      |
| Lap number | 6     | uint8     | Raw (Master-LapCh)                                                                                                                                                          |
| (GPS hour) | 7     | uint8     | **No longer broadcast** — RS3 dropped it; timezone comes from the `tz_offset` tunable. Firmware tolerates the byte's presence or absence (parses it only if `length >= 8`). |

### 0x711 — AiM Brakes + Session Time (50 Hz)

| Field                | Bytes | Type      | Notes                                                                                                                                                                                                                                             |
| -------------------- | ----- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Front brake pressure | 0–1   | int16 BE  | **Not the source of truth at `brake_front_source` = 1 (the default).** The front sensor moved to GCU analog A4 and is published on 0x704; this field is whatever the dash still has, and on a rewired car it is dead. Only consulted at source 0. |
| Rear brake pressure  | 2–3   | int16 BE  | Signed brake pressure in **psi**. Still the live rear channel, and the standby source the lamp falls back to when the front channel latches out.                                                                                                  |
| Rolling session time | 4–7   | uint32 BE | Assumed ms (`AIM_SESS_TIME_MS_PER_COUNT`)                                                                                                                                                                                                         |

**Brake channels are signed psi — no conversion.** The RS3 CAN payload is configured
with Data Format = Signed integer, Unit = psi, Multiplier 1, Offset 0, so the raw 16-bit
value IS the pressure once reinterpreted as `int16`. A released pedal reads slightly
negative (`0xFFFE` = −2 psi at rest). The firmware stores the raw 16 bits and reinterprets
as signed psi at every boundary; the AIM line (`bf=`/`br=`) and shift trace (`bf:[]`/`br:[]`)
render the signed value directly. There is no mV scaling.

Sensor-fault exclusion works on the signed psi: a channel latches out at or below
`BRAKE_EXCL_MIN_PSI` (−25 psi, disconnected/ground-offset) or at or above `BRAKE_EXCL_MAX_PSI`
(1050 psi, short to rail) — both bounds inclusive. The stale/absent marker is
`BRAKE_SENTINEL` (`0x8000` = −32768 psi, unphysical).

### 0x712 — AiM Dynamics (10 Hz)

| Field             | Bytes | Type      | Notes                                     |
| ----------------- | ----- | --------- | ----------------------------------------- |
| Throttle position | 0–1   | uint16 BE | Raw; per-car RS3 scaling, may exceed 100  |
| Lateral G         | 2–3   | int16 BE  | Raw signed; + = right                     |
| Longitudinal G    | 4–5   | int16 BE  | Raw signed; + = acceleration, − = braking |
| (reserved)        | 6–7   | —         | Unused                                    |

Broadcast at 10 Hz — the rate of the underlying AiM channels (throttle off
the ECU CAN, G off GPS), so there's no point sending faster. In the 10 ms
shift trace this lands as ~3 distinct samples across a 300 ms shift. Throttle
and G freshness are tracked independently (`AIM_INPUT_TIMEOUT_MS` = 5 s each).
Throttle is
available in the shift trace (`thr:[...]` array) and in failure-class flash
records (`thr:N`). G channels appear in failure-class records only
(`latg:N long:N`). Firmware is the source of truth — configure RS3 to match.

GPS speed, lap number, and brake pressures are stored raw with freshness
tracking (`AIM_INPUT_TIMEOUT_MS` = 5 s). GPS speed and lap number are
storage-only. The **rear brake pressure is live**: it is the standby source the
brake light falls back to when the analog front channel latches out, and it is
mirrored onto 0x704. That fallback path uses the tighter `BRAKE_LIGHT_STALE_MS`
(200 ms) window, not the 5 s channel timeout. The time channels drive log
timestamps:

- **UNIX time** anchors the GCU wall clock (`src/aim_input.cpp`): epochs
  below 2025-01-01 are rejected (AiM sends 0/garbage before GPS lock), the
  first valid fix anchors and writes a `TIMESYNC` flash record (millis ↔
  epoch mapping for log post-processing), and the clock re-anchors + re-logs
  only when the extrapolated time drifts ≥ 5 s from GPS.
- **GPS hour** vs the UTC epoch derives the car's timezone offset (whole
  hours only, normalized to (-12, +12]; samples within 60 s of a UTC hour
  boundary are ignored to avoid an off-by-one latch while the two dash
  channels tick over independently).
- **Rolling session time** + wall clock annotate failure-class log lines
  (failed shifts, safety blocks, CAN/GP faults — `logEventIsFailure()`):
  `[<ms>] 2026-06-12T13:00:36 (01:30.150 sess) SHIFT_CL_TIMEOUT ...`
  where the ISO time is car-local. Both parts degrade independently to
  nothing when no dash/fix is present.

`DUMP_STATUS` reports the current wall clock + raw channels
(`AIM epoch=... speed_raw=... lap=... sess_raw=...`); the flasher GUI
renders any `epoch=` field in the operator's local timezone.

---

## Data Freshness & Timeouts

| Signal | Timeout | Effect                                                           |
| ------ | ------- | ---------------------------------------------------------------- |
| RPM    | 1500 ms | `rpmValid` → false; shifts may be blocked                        |
| Gear   | 1500 ms | `canGearValid` → false; falls back to GP sensor or fallback mode |
| TPS    | 500 ms  | `tpsValid` → false                                               |
| AiM    | 5000 ms | `aimMasterFresh()`/`aimBrakesFresh()` → false (informational)    |

---

## Three-Mode Degradation

| Mode            | Condition               | Shift timing                  | Safety gates                            |
| --------------- | ----------------------- | ----------------------------- | --------------------------------------- |
| **Closed-loop** | GP sensor valid         | GP detects gear (~5 ms)       | CAN: money shift, gear range            |
| **Gear-aware**  | GP invalid, CAN valid   | Time-based (pressure-derived) | CAN: money shift, gear range            |
| **Full-open**   | GP invalid, CAN invalid | Time-based (pressure-derived) | None — driver has manual responsibility |

Mode is latched at shift start to prevent mid-shift transitions.

---

## Safety Gates

### Upshift validation

- **Block** if result RPM < 1000 (MIN_RPM) — would stall
- **Block** if already in 6th gear
- **Allow** from neutral regardless of RPM
- **Allow unconditionally** in full-open mode

### Downshift validation (money shift prevention)

- Projected RPM = current RPM × (ratio[N-1] / ratio[N])
- **Block** if projected RPM ≥ `downshift_block_rpm` (default 11,800)
- **Block** if in neutral or 1st gear
- Downshift from 1st → half-upshift to neutral (progressive pulses)
- **Allow unconditionally** in full-open mode

### Gear ratios (Suzuki GSX-S / GSX-S 1000)

| Gear | Ratio |
| ---- | ----- |
| 1    | 2.562 |
| 2    | 2.052 |
| 3    | 1.714 |
| 4    | 1.500 |
| 5    | 1.360 |
| 6    | 1.269 |

---

## CAN Error Recovery

- **Consecutive errors:** After 10 consecutive parse/send errors, `CAN.end()` → 10ms delay → `CAN.begin(500000)`
- **Bus-Off pre-emption:** The Adafruit CAN library's `bus_autorecover()` has an infinite while-loop that hangs if the bus stays down. `checkBusOff()` runs before every `readFromCAN()` and CAN send, checking the MCAN PSR.BO flag directly. If Bus-Off is detected, `CAN.end()` runs immediately (before the library can spin) and a non-blocking reset is scheduled. All CAN sends are skipped while `canResetPending` is true.
- **EMI context:** Solenoid switching (especially the DOWN valve) can corrupt the CAN bus. Bus-Off events are tracked in `canBusOffCount` and reported in the heartbeat (`bo=`) and on CAN 0x701 byte 7.
