Back to Projects
Picture of the University of Toronto Formula Racing car in a race

UTFR Wireless Telemetry

September 2025 - April 2026
RustRaspberry PiESP32-C6BLE

Hardware

CAN Relay PCB

The custom four-layer PCB is built around the ESP32-C6, with dual CAN transceivers, a DS3231 temperature-compensated RTC with coin cell backup for persistent timestamps across power cycles, and a MicroSD socket for local logging. The external antenna connects via W.FL, allowing it to be routed outside the enclosure for optimal RF performance.

Telemetry Link

The firmware runs a hybrid of async tasks (edge-executor on the main thread) and RTOS threads (spawned through ESP-IDF). CAN and BLE tasks run async since their drivers support it, keeping the executor idle between frames. SD logging and pose estimation run as RTOS threads - the logger because SD writes are blocking (text serialization to SocketCAN format plus write_all()), and pose because floating-point Kalman filter math would add latency to the lightweight async executor. Tasks communicate through typed channels and signals: channels for ordered, buffered delivery (CAN frames to the logger) and signals for latest-value semantics (IMU readings to pose, BLE TX to the wireless task).

Frames are serialized with postcard and framed with COBS before transmission. postcard produces compact binary without JSON overhead, and COBS provides self-delimiting frames without escape sequences, allowing multiple frames to be packed end-to-end within a single BLE notification MTU for maximum bitrate.

---
  config:
    layout: elk
    elk:
      nodePlacementStrategy: SIMPLE
      direction: DOWN
---
flowchart TB
    subgraph relay_async["LocalExecutor (Async)"]
        can_task["can_task()<br>(can_interface/mod.rs)"]
        ble_task["ble_task()<br>(ble/mod.rs)"]
    end
    subgraph relay_threads["RTOS Threads"]
        sd_logger["sd_logger thread<br>(sd_logger.rs)"]
        pose["pose thread<br>(pose_estimation.rs)"]
    end
    subgraph relay["CAN Relay (can_relay/src/)"]
        relay_async
        relay_threads
        can_bus[("CAN Bus<br>(transceivers)")]
        sd_card[("SD Card")]
        rtc[("RTC<br>(DS3231)")]
    end
    subgraph receiver_async["LocalExecutor (Async)"]
        main_loop["main_loop()<br>(main.rs)"]
    end
    subgraph receiver_threads["RTOS Thread"]
        stdin_reader["stdin_reader thread<br>(stdin.rs)"]
    end
    subgraph receiver["CAN Receiver (can_receiver/src/)"]
        receiver_async
        receiver_threads
        usb[("USB Serial<br>→ Host PC")]
    end
    can_task -- "LOG_CHANNEL<br>CanFrameForSd" --> sd_logger
    sd_logger --> sd_card
    sd_logger -. "SD_STATUS<br>Signal" .-> ble_task
    rtc -.-> sd_logger
    can_task -. "BLE_TX_ONESHOT<br>Signal" .-> ble_task
    can_task -. "ACCEL_SIGNAL<br>Signal" .-> pose
    can_task -. "GYRO_SIGNAL<br>Signal" .-> pose
    ble_task -- "CAN_WRITE_CHANNEL<br>protocol::CanFrame" --> can_task
    can_task <--> can_bus
    usb --> stdin_reader
    stdin_reader -- "STDIN_COMMAND_CHANNEL<br>Command" --> main_loop
    main_loop -- JSON telemetry --> usb
    ble_task <==BLE Wireless<br>COBS + Postcard<br>CanFrame==> main_loop

Base Station

The pit-side receiver ESP32-C6 forwards decoded frames over USB to a Raspberry Pi running a Docker Compose stack. A can_bridge service written in Rust deserializes incoming signals and publishes each one to a Mosquitto MQTT broker under can/<bus>/<message>/<signal>. From there, any device on the pit LAN can subscribe to whichever topics it needs — Grafana reads them directly to populate the live dashboards, and anyone can write custom analysis scripts in any language with an MQTT client. The broker also exposes MQTT over WebSocket, so browser-based tools work without any extra infrastructure.

An mDNS service advertises telemetry.local on the network, meaning the Grafana dashboards and MQTT broker are reachable by hostname from any laptop on the LAN without needing to know the Pi’s IP address.