hsm_firmware/animation.rs
1// Copyright (c) 2026 Tuloup Simon
2//
3// This program is free software: you can redistribute it and/or modify
4// it under the terms of the GNU General Public License as published by
5// the Free Software Foundation, either version 3 of the License, or
6// any later version.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! LED animation task.
17//!
18//! Reads the current [`hsm_firmware_logic::TokenState`] off
19//! [`crate::channels::TOKEN_STATE`] and drives the green (GP16) and yellow
20//! (GP17) LEDs at the cadence dictated by the state's
21//! [`hsm_firmware_logic::LedPattern`].
22//!
23//! The task runs an infinite loop with two interleaved concerns:
24//!
25//! 1. **Apply the current pattern.** Each pattern has a tick interval and
26//! a per-tick action (toggle green, toggle yellow, alternate, etc).
27//! The task sleeps for the tick interval, then performs the action.
28//! 2. **React to state changes promptly.** Sleeping for a 500 ms tick
29//! would make a transition into `Error` invisible for half a second.
30//! To avoid that, the task races the tick timer against
31//! [`crate::channels::TOKEN_STATE`] using `embassy_futures::select`.
32//! Whichever fires first wins.
33//!
34//! # Testability
35//!
36//! The actual driving logic lives in [`apply_pattern`], which is generic
37//! over the [`Led`] trait. The embassy task wrapper
38//! [`animation_task`] is a thin shell that supplies real `Output<'static>`
39//! values; host-side tests instantiate `apply_pattern` directly with
40//! mock LEDs to assert on transition counts.
41
42use defmt::info;
43use embassy_futures::select::{select, Either};
44use embassy_time::{Duration, Timer};
45
46use hsm_firmware_logic::{Led, LedPattern};
47
48use crate::channels::TOKEN_STATE;
49use crate::hal_rp2040::Rp2040Led;
50
51/// Tick interval for the slow pulse pattern (green, 1 Hz).
52const SLOW_PULSE_INTERVAL: Duration = Duration::from_millis(500);
53
54/// Tick interval for the fast blink pattern (5 Hz).
55const FAST_BLINK_INTERVAL: Duration = Duration::from_millis(100);
56
57/// Tick interval for the yellow blink pattern (2 Hz).
58const YELLOW_BLINK_INTERVAL: Duration = Duration::from_millis(250);
59
60/// Tick interval for the alternate-both pattern (5 Hz).
61const ALTERNATE_INTERVAL: Duration = Duration::from_millis(100);
62
63/// Drive the LEDs based on the current operating state. Spawn once at boot.
64///
65/// Both LEDs are owned by this task for the lifetime of the firmware.
66#[embassy_executor::task]
67pub(crate) async fn animation_task
68(
69 mut led_green: Rp2040Led,
70 mut led_yellow: Rp2040Led,
71) -> !
72{
73 // Wait for the first state publication. The state task always emits
74 // the initial state at startup, so this returns promptly.
75 let mut state = TOKEN_STATE.wait().await;
76 info!("animation task started, initial state {:?}", state);
77
78 // Tick phase: toggles every iteration to drive blink/pulse patterns.
79 let mut tick_high = false;
80
81 loop
82 {
83 // Apply the current pattern via the generic helper.
84 let interval = apply_pattern
85 (
86 state.led_pattern(),
87 tick_high,
88 &mut led_green,
89 &mut led_yellow,
90 );
91 tick_high = !tick_high;
92
93 // Race the tick timer against a state change.
94 match select(Timer::after(interval), TOKEN_STATE.wait()).await
95 {
96 Either::First(()) =>
97 {
98 // Tick elapsed, continue with the same pattern.
99 }
100 Either::Second(new_state) =>
101 {
102 // State changed: reset the tick phase and switch pattern
103 // immediately.
104 info!("animation: state {:?} -> {:?}", state, new_state);
105 state = new_state;
106 tick_high = false;
107 }
108 }
109 }
110}
111
112/// Apply one tick of `pattern` to the LEDs and return the duration to wait
113/// before the next tick.
114///
115/// `tick_high` alternates `false`/`true` each iteration. For static
116/// patterns (solid, all-off) the value is ignored.
117///
118/// Generic over [`Led`] so the firmware task and host-side unit tests
119/// can share the same code path.
120pub(crate) fn apply_pattern<G, Y>
121(
122 pattern: LedPattern,
123 tick_high: bool,
124 led_green: &mut G,
125 led_yellow: &mut Y,
126) -> Duration
127where
128 G: Led,
129 Y: Led,
130{
131 match pattern
132 {
133 LedPattern::AllOff =>
134 {
135 led_green.off();
136 led_yellow.off();
137 // No animation: long wait, the select will wake us on the
138 // next state change.
139 Duration::from_secs(60)
140 }
141 LedPattern::GreenSolid =>
142 {
143 led_green.on();
144 led_yellow.off();
145 Duration::from_secs(60)
146 }
147 LedPattern::GreenSlowPulse =>
148 {
149 led_yellow.off();
150 led_green.set(tick_high);
151 SLOW_PULSE_INTERVAL
152 }
153 LedPattern::GreenFastBlink =>
154 {
155 led_yellow.off();
156 led_green.set(tick_high);
157 FAST_BLINK_INTERVAL
158 }
159 LedPattern::YellowSolid =>
160 {
161 led_green.off();
162 led_yellow.on();
163 Duration::from_secs(60)
164 }
165 LedPattern::YellowBlink =>
166 {
167 led_green.off();
168 led_yellow.set(tick_high);
169 YELLOW_BLINK_INTERVAL
170 }
171 LedPattern::AlternateBoth =>
172 {
173 led_green.set(tick_high);
174 led_yellow.set(!tick_high);
175 ALTERNATE_INTERVAL
176 }
177 }
178}
179
180#[cfg(test)]
181mod tests
182{
183 use super::*;
184
185 /// Minimal mock LED that just tracks state and transitions.
186 /// Mirrors the one in `hsm_firmware_logic::io::test_mocks` but lives
187 /// here so this crate can test `apply_pattern` without exposing a
188 /// `pub(crate)` mock across crates.
189 #[derive(Debug, Default)]
190 struct MockLed
191 {
192 state: bool,
193 transitions: u32,
194 }
195
196 impl Led for MockLed
197 {
198 fn on(&mut self)
199 {
200 if !self.state
201 {
202 self.transitions += 1;
203 }
204 self.state = true;
205 }
206
207 fn off(&mut self)
208 {
209 if self.state
210 {
211 self.transitions += 1;
212 }
213 self.state = false;
214 }
215 }
216
217 #[test]
218 fn green_solid_lights_green_and_kills_yellow()
219 {
220 let mut green = MockLed::default();
221 let mut yellow = MockLed::default();
222 green.off();
223 yellow.on();
224 let _ = apply_pattern(LedPattern::GreenSolid, false, &mut green, &mut yellow);
225 assert!(green.state);
226 assert!(!yellow.state);
227 }
228
229 #[test]
230 fn alternate_both_swaps_each_tick()
231 {
232 let mut green = MockLed::default();
233 let mut yellow = MockLed::default();
234 let _ = apply_pattern(LedPattern::AlternateBoth, true, &mut green, &mut yellow);
235 assert!(green.state);
236 assert!(!yellow.state);
237 let _ = apply_pattern(LedPattern::AlternateBoth, false, &mut green, &mut yellow);
238 assert!(!green.state);
239 assert!(yellow.state);
240 }
241
242 #[test]
243 fn all_off_clears_both()
244 {
245 let mut green = MockLed::default();
246 let mut yellow = MockLed::default();
247 green.on();
248 yellow.on();
249 let _ = apply_pattern(LedPattern::AllOff, true, &mut green, &mut yellow);
250 assert!(!green.state);
251 assert!(!yellow.state);
252 }
253
254 #[test]
255 fn green_slow_pulse_returns_500ms_interval()
256 {
257 let mut green = MockLed::default();
258 let mut yellow = MockLed::default();
259 let d = apply_pattern(LedPattern::GreenSlowPulse, true, &mut green, &mut yellow);
260 assert_eq!(d, SLOW_PULSE_INTERVAL);
261 }
262}