CAN
FDCAN1, classic CAN frames, 500 kbps, on PA11 (RX) / PA12 (TX). Configuration is in Core/Src/fdcan.c; the shared ring-buffer/scheduler driver is in Drivers/common/drivers/can/generic_can_driver.c (identical across the ARTTU firmware repos, don't fork it here); board-specific message packing/unpacking is in Core/Src/App/Communication/can_comm.c.
RX path
The RX ISR copies the frame into a ring buffer and sets a thread flag; canRxTask wakes, drains the buffer, and process_can_frames() decodes each recognized ID into the matching field of can_input_data. Unrecognized IDs are silently ignored, so only frames matching the FDCAN hardware filters (see below) even reach this switch.
Filters
Three dual filters (StdFiltersNbr = 3) admit six standard IDs to RX FIFO0; everything else is rejected at the hardware level (FDCAN_REJECT on both non-matching standard and extended, and on remote frames):
| Filter | IDs |
|---|---|
| 0 | BMS_SoC_Voltage (17), BMS_Current (21) |
| 1 | BMS_Errors_Temps (16), GPS_Angles (240) |
| 2 | GIL_Inv_Data_2 (33), GIR_Inv_Data_2 (49) |
Adding a new RX message means adding both a filter entry here and a case in process_can_frames(), in the same PR.
Message table (RX)
| DBC message | ID | Fields used | Notes |
|---|---|---|---|
BMS_SoC_Voltage | 17 | Adaptive_SOC, Pack_Inst_Voltage | See the byte-offset gotcha below |
BMS_Current | 21 | Pack_Current | Same gotcha |
BMS_Errors_Temps | 16 | High_Temperature | |
GPS_Angles | 240 | GPS_Speed | Little-endian on the wire, see below |
GIL_Inv_Data_2 | 33 | Motor_Temp_L, Inverter_Temp_L | Left-side motor/inverter temps, feeds the fan hysteresis |
GIR_Inv_Data_2 | 49 | Motor_Temp_R, Inverter_Temp_R | Right-side, same purpose |
TX path
canTxTask wakes on whichever comes first: a CAN_TX_WAKE_FLAG thread flag (set whenever a button changes, and also from the FDCAN TX-empty/TX-complete ISRs to drain the queue promptly), or a 1 ms timeout. Every wake it calls set_can_frames() to repack the current ui_data state into DASH_Buttons (ID 0x0D0 / 208), sends it immediately if the buttons changed, runs the periodic scheduler for any periodic frames, and drains the TX ring buffer into the hardware FIFO.
Message table (TX)
| DBC message | ID | Payload | Notes |
|---|---|---|---|
DASH_Buttons | 208 (0x0D0) | Byte 0: bitmask of apps_recalibrate, steer_recalibrate, suspension_recalibrate, imu_recalibrate, activate_cooling, slip_control_on_off, sd_logging_on_off, telemetry_on_off. Byte 1: slip_control_mode_enum (1-12) | Non-periodic; sent on change, plus once after the delayed startup timer |
Delayed startup frame
init_can_tasks() (can_tasks.c) arms a one-shot FreeRTOS software timer (osTimerNew(..., osTimerOnce, ...), 3000 ms) instead of sending the flash-loaded button state onto the bus the instant the RTOS boots. On expiry, its callback sets ui_data.buttons_updated_flag and wakes canTxTask, which sends the first real DASH_Buttons frame. This was a deliberate change: firing a frame the instant the dashboard's own tasks exist, before the rest of the vehicle network is necessarily up, is not useful and was moved out of flash_config.c's boot-time load path into this timer.
Two real bugs found on this board
Both were found and fixed during bring-up of this exact firmware and are worth knowing about, since they don't announce themselves as CAN bugs at first glance.
The silent NULL task handle bug
Symptom: HAL_FDCAN_RxFifo0Callback was confirmed (via breakpoint) to fire on every incoming frame, but osThreadFlagsSet(canRxTaskHandle, ...) was never actually reached, because canRxTaskHandle was NULL, permanently, not intermittently. The same was true of canTxTaskHandle. Received CAN data never updated the UI, and touching a button never produced a TX frame, at the same time, because both tasks had silently failed to be created.
Root cause: configTOTAL_HEAP_SIZE in Core/Inc/FreeRTOSConfig.h was set to 10000 bytes. GUI_Task alone requests an 8192-byte stack; add defaultTask's 512 bytes and there was nowhere near enough heap left for canRxTask and canTxTask's 2048-byte stacks each, created last in MX_FREERTOS_Init(). osThreadNew() on a CMSIS-RTOS2/FreeRTOS heap_4 port returns NULL on allocation failure. No crash, no log, just a task that silently never exists.
Fix: raised configTOTAL_HEAP_SIZE to 49152 bytes (also updated the matching field in the .ioc so a CubeMX regeneration doesn't revert it). The MCU has 640 KB of RAM and the board was using roughly 94 KB of it at the time, so there was ample room.
If you add a new task, thread, timer, or RTOS object
Check the heap budget. osThreadNew/osTimerNew/osSemaphoreNew/osMessageQueueNew all draw from the same configTOTAL_HEAP_SIZE pool, and none of them fail loudly if it runs out.
Byte-offset and endianness bugs in the manual DBC decode
This project decodes CAN payloads by hand (can_comm.c) rather than from generated DBC code, using small U16/S16 macros over raw payload bytes. Three real decode bugs were found this way:
Pack_Inst_Voltage(BMS_SoC_Voltage, bit 23 in the DBC, meaning bytes 2-3) was being read from bytes 0-1, which is actually a different signal in the same message (Pack_Open_Voltage). The value looked plausible, since it was a real voltage-shaped number, just the wrong one.Pack_Current(BMS_Current, bit 23, bytes 2-3) had the same class of bug, readingAverage_Current's byte offset instead.Adaptive_SOC(BMS_SoC_Voltage, bit 63, byte 7) was being read from byte 6, landing onPack_SOCinstead.GPS_Speed(GPS_Angles) is0|16@1-in the DBC: the@1means little-endian (Intel) byte order, unlike every other signal decoded in this file, which is@0(big-endian/Motorola). The decode had the two payload bytes swapped.
None of these produced an obviously broken value (0, garbage, out of range); they produced a real, plausible-looking number for the wrong signal, or a wrong-but-not-absurd number from swapped bytes. That's what made them worth documenting: a manual byte-level DBC decode does not fail loudly when the offset or endianness is wrong, it just quietly reads a neighboring signal or a byte-reversed value.
When adding a new signal to can_comm.c
Always check both the start_bit and the @0/@1 byte-order marker in MAIN_DBC.dbc for that exact signal, not just the message. Signals within the same message can have different byte orders (as GPS_Angles shows), and a start bit of N in Motorola/big-endian numbering means byte N / 8, not byte N / 8 rounded the way you might assume from the field's position in the struct.
