
UTFR Wireless Telemetry
Hardware
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.