Signal channels
Cross-task communication runs through typed Signal<RawMutex, T>
channels — embassy_sync::signal::Signal. Each channel has exactly
one producer and one consumer, latest-wins semantics, and never
blocks. SSE fan-out is the deliberate exception: it uses
embassy_sync::pubsub::PubSubChannel because /state/stream has
multiple concurrent subscribers; see HTTP control plane for
how that channel is sized and exhausted.
The pattern
// Producer side (per-peripheral task):
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::signal::Signal;
pub static SOMETHING_SIGNAL: Signal<CriticalSectionRawMutex, ValueType>
= Signal::new();
// Inside the producer's loop:
SOMETHING_SIGNAL.signal(my_value);
// Consumer side (render task):
if let Some(value) = SOMETHING_SIGNAL.try_take() {
entity.perception.something = Some(value);
}
Why try_take and not wait
The render task drains every signal once per 30 FPS frame. Using
signal.wait() would block until a value arrives — the render task
can’t afford to block. try_take() is non-blocking: returns Some(v)
if a value is waiting (and consumes it), None otherwise. Dropped
values are fine because the next signal() overwrites the channel
with the latest reading.
This shape works because every signal we care about is a
latest-wins observation: “what’s the current ambient lux?” not
“how many lux readings have I missed?”. The exception is event-driven
signals (TAP_SIGNAL, REMOTE_SIGNAL); for those, a missed signal
means a missed tap, but they fire so rarely (sub-Hz) that the render
task’s 33 ms tick virtually never coincides with the signal write.
Catalog
| Signal | Producer | Consumer | Cadence |
|---|---|---|---|
audio::AUDIO_RMS_SIGNAL |
audio task RX RMS loop | render → MouthFromAudio + EmotionFromVoice |
~33 ms |
touch::TAP_SIGNAL |
touch task / button task | render → EmotionFromTouch |
event |
ir::REMOTE_SIGNAL |
IR RMT task | render → EmotionFromRemote |
event |
imu::IMU_SIGNAL |
IMU polling | render → entity.perception.accel_g, .gyro_dps |
10 ms |
ambient::AMBIENT_LUX_SIGNAL |
LTR-553 polling | render → entity.perception.ambient_lux |
500 ms |
power::POWER_STATUS_SIGNAL |
AXP2101 polling | render → entity.perception.battery_percent |
1000 ms |
head::POSE_SIGNAL |
render task | head task → SCServo | 33 ms |
head::HEAD_POSE_ACTUAL_SIGNAL |
head task readback | render → entity.motor.head_pose_actual |
1000 ms |
leds::LED_FRAME_SIGNAL |
render task | led task → PY32 | 33 ms |
camera::CAMERA_FRAME_SIGNAL |
camera DMA task | render task → blit | gated |
camera::CAMERA_MODE_SIGNAL |
button task | render + camera tasks | event |
camera::CAMERA_CAPTURE_REQUEST |
render task | camera task | event |
Watchdog supervision
The watchdog task (see src/watchdog.rs) doesn’t peek the signals —
that would race the render task’s drain. Instead, each periodic
producer increments an AtomicU32 heartbeat counter once per loop
iteration, and the watchdog polls those counters every 5 s. When a
counter doesn’t advance enough relative to its expected cadence, the
watchdog logs WARN watchdog: channel '<name>' silent. See
architecture for the full task graph and watchdog
placement.