Skip to main content

linkage_blaze/examples/
skeleton_clock.rs

1//! A clock display example that renders a motion-captured skeleton.
2//!
3//! The included asset originated in the Biovision Hierarchy motion-capture
4//! format and is stored as a compile-time `.lb.rs` linkage asset.
5
6use core::{array::from_fn, convert::Infallible, fmt};
7
8use crate::{Error as LinkageError, LinkageFixed, Rgb888, linkage_file, render::Projection};
9use device_envoy_core::{
10    UnwrapInfallible,
11    button::Button,
12    clock_sync::{ClockSync, h12_m_s},
13};
14use embassy_futures::select::{Either, select};
15use embedded_graphics::{
16    Drawable,
17    mono_font::{MonoFont, MonoTextStyle, ascii::FONT_7X13, ascii::FONT_10X20},
18    pixelcolor::Rgb565,
19    prelude::{DrawTarget, Point, Size},
20    primitives::Rectangle,
21    text::{Alignment, Baseline, Text, TextStyleBuilder},
22};
23use log::info;
24use time::OffsetDateTime;
25
26use device_envoy_core::cyd::{
27    CydDisplay,
28    display::{
29        CydFrame, DrawItem, Image565Fixed, Image888Fixed, MaskFixed, MaskedDrawable, Orientation,
30        mask_byte_count, tga, tiling::TileGrid,
31    },
32};
33
34// ── Palette ──────────────────────────────────────────────────────────────────
35
36/// Near-black warm-charcoal background.
37pub const BACKGROUND_COLOR: Rgb888 = Rgb888::new(13, 13, 11);
38const FIGURE_COLOR: Rgb888 = Rgb888::new(255, 214, 123); // warm pale gold (255, 214, 123)
39/// Warm pale-gold figure color.
40pub const FOREGROUND_COLOR: Rgb888 = Rgb888::new(255, 214, 123);
41const PLACARD_TEXT_COLOR: Rgb888 = BACKGROUND_COLOR; // dark text on the light sign face
42
43// ── Linkage ────────────────────────────────────────────────────────────
44
45// Load the motion-capture linkage converted *.bvh -> *.lb.rs.
46linkage_file! {
47    pirouette {
48        file: "../assets/mocap/pirouette.lb.rs",
49    }
50}
51
52// Prepend a linkage drawing style.
53const STYLE: LinkageFixed<0, 0, 3> = LinkageFixed::start().pen_width(3.5).pen_color(FIGURE_COLOR);
54const CLOCK_PARAM_NAMES: &[&str] = &["head_yrotation", "l_shldr_zrotation", "r_shldr_zrotation"];
55const LINKAGE_WITH_STYLE: LinkageFixed<
56    { pirouette::DOF },
57    { pirouette::MARKS },
58    { STYLE.step_count() + pirouette::STEP_COUNT - 1 },
59> = STYLE.combine(pirouette::view());
60
61// Turn the left foot out jauntily, then keep only the three clock-driven parameters.
62// TOD00API Specialization changes DOF while retaining the fixed backing capacity.
63const LINKAGE: LinkageFixed<
64    { CLOCK_PARAM_NAMES.len() },
65    { LINKAGE_WITH_STYLE.mark_count() },
66    { LINKAGE_WITH_STYLE.step_count() },
67> = LINKAGE_WITH_STYLE
68    .freeze_param_name::<{ pirouette::DOF - 1 }>("l_shin_yrotation", 57.6)
69    .retain_param_names(CLOCK_PARAM_NAMES);
70
71// ── Projection ───────────────────────────────────────────────────────────────
72
73const PROJECTION: Projection = Projection::front_orthographic(
74    Point::new(139, 306), // target origin
75    1.35,                 // scale
76);
77
78// ── Background_bitmap ──────────────────────────────────────────────────────────
79
80/// Clock-face background_bitmap, loaded at compile time.
81const BACKGROUND_BITMAP: Image565Fixed<240, 320, { 240 * 320 }> =
82    tga!("../assets/clock_back.small.tga").to_565();
83
84const HOURS_SIGN_TGA: Image888Fixed<45, 73, { 45 * 73 }> = tga!("../assets/hours.small.tga");
85const HOURS_SIGN_BITMAP: Image565Fixed<45, 73, { 45 * 73 }> = HOURS_SIGN_TGA.to_565();
86const HOURS_SIGN_MASK: MaskFixed<45, 73, { mask_byte_count(45, 73) }> =
87    HOURS_SIGN_TGA.to_mask_magenta();
88const HOURS_SIGN_ANCHOR_X: f32 = 22.0;
89const HOURS_SIGN_VALUE_CENTER: Point = Point::new(22, 50);
90
91const MINUTE_SIGN_TGA: Image888Fixed<45, 77, { 45 * 77 }> = tga!("../assets/minute.small.tga");
92const MINUTE_SIGN_BITMAP: Image565Fixed<45, 77, { 45 * 77 }> = MINUTE_SIGN_TGA.to_565();
93const MINUTE_SIGN_MASK: MaskFixed<45, 77, { mask_byte_count(45, 77) }> =
94    MINUTE_SIGN_TGA.to_mask_magenta();
95const MINUTE_SIGN_ANCHOR_X: f32 = 22.0;
96const MINUTE_SIGN_VALUE_CENTER: Point = Point::new(22, 56);
97
98// ── Screen / tile layout ─────────────────────────────────────────────────────
99
100/// Display orientation used by the skeleton-clock renderer.
101pub const ORIENTATION: Orientation = Orientation::Portrait;
102/// Font used for the clock placards.
103pub const TOP_FONT: MonoFont<'static> = FONT_7X13;
104/// Rectangle reserved for Wi-Fi status text.
105pub const WIFI_STATUS_RECTANGLE: Rectangle = Rectangle::new(Point::new(6, 6), Size::new(155, 14));
106const TIME_RECTANGLE: Rectangle = Rectangle::new(
107    Point::new(
108        WIFI_STATUS_RECTANGLE.top_left.x + WIFI_STATUS_RECTANGLE.size.width as i32,
109        WIFI_STATUS_RECTANGLE.top_left.y,
110    ),
111    Size::new(
112        ORIENTATION.width() - WIFI_STATUS_RECTANGLE.size.width,
113        WIFI_STATUS_RECTANGLE.size.height,
114    ),
115);
116
117// The figure starts below the top-level display. We will tile to save memory.
118const FIGURE_Y: u32 = if WIFI_STATUS_RECTANGLE.top_left.y as u32 + WIFI_STATUS_RECTANGLE.size.height
119    > TIME_RECTANGLE.top_left.y as u32 + TIME_RECTANGLE.size.height
120{
121    WIFI_STATUS_RECTANGLE.top_left.y as u32 + WIFI_STATUS_RECTANGLE.size.height
122} else {
123    TIME_RECTANGLE.top_left.y as u32 + TIME_RECTANGLE.size.height
124};
125/// Tile layout used to draw the skeleton figure.
126pub const FIGURE_TILE_GRID: TileGrid = TileGrid::new(
127    Rectangle::new(
128        Point::new(0, FIGURE_Y as i32),
129        Size::new(ORIENTATION.width(), ORIENTATION.height() - FIGURE_Y),
130    ),
131    3,
132    3,
133);
134// ── Main function ────────────────────────────────────────────────────────
135
136/// Run the skeleton-clock render loop until the physical BOOT button requests a
137/// Wi-Fi reset, driven by `clock_sync` ticks and drawn onto `cyd`.
138pub async fn run<CydDisplayDevice, ClockSyncDevice>(
139    display: &mut CydDisplayDevice,
140    clock_sync: &ClockSyncDevice,
141    button: &mut impl Button,
142) -> Result<Exit, Error<CydDisplayDevice::Error>>
143where
144    CydDisplayDevice: CydDisplay,
145    ClockSyncDevice: ClockSync,
146{
147    loop {
148        // Wait for a tick and get the time.
149        let tick = match select(button.wait_for_press(), clock_sync.wait_for_tick()).await {
150            Either::First(()) => return Ok(Exit::ResetWifi),
151            Either::Second(tick) => tick,
152        };
153        let local_time = &tick.local_time;
154        let (hour_12, minute, _) = h12_m_s(local_time);
155        info!("tick {}", text_24h(local_time));
156
157        // Write the digital time.
158        display
159            .frame_mut(TIME_RECTANGLE)
160            .write_text(&text_12h(local_time))
161            .flush()
162            .await
163            .map_err(Error::Flush)?;
164
165        // Convert the time into normalized angles for the figure's
166        // the head (seconds), right arm (minutes) and left arm (hours).
167        let params = linkage_params(local_time);
168
169        // Create an iterator that will list every 3D item and its pose.
170        let linkage = LINKAGE.view();
171        let mut draw_items_3d = linkage.draw_items_3d(&params)?;
172
173        // // Iterate 3d items, project to 2D, and collect 2D items and poses.
174        let mut projected_items =
175            heapless::Vec::<_, { LINKAGE.view().draw_item_3d_count() }>::new();
176        for draw_item_3d in &mut draw_items_3d {
177            projected_items
178                .push(draw_item_3d.project(&PROJECTION))
179                .map_err(Error::VecOverflow)?;
180        }
181
182        // Find the positions of the middle fingers after evaluating the linkage.
183        let (hours_anchor_x, hours_anchor_y) = draw_items_3d
184            .pose_by_mark_name("lMid2")?
185            .project(&PROJECTION);
186        let (minute_anchor_x, minute_anchor_y) = draw_items_3d
187            .pose_by_mark_name("rMid2")?
188            .project(&PROJECTION);
189
190        // Figure out where to draw the hour and minute placards.
191        let hours_top_left = Point::new(
192            (hours_anchor_x - HOURS_SIGN_ANCHOR_X) as i32,
193            hours_anchor_y as i32,
194        );
195        let minute_top_left = Point::new(
196            (minute_anchor_x - MINUTE_SIGN_ANCHOR_X) as i32,
197            minute_anchor_y as i32,
198        );
199
200        // Replay the complete screen-coordinate scene once per tile. The
201        // callback owns neither the reusable buffer nor the flush sequence.
202        display
203            .for_each_tile(FIGURE_TILE_GRID, |tile| {
204                BACKGROUND_BITMAP.draw(tile).unwrap_infallible();
205
206                // Draw the projected items from the linkage.
207                for projected_item in &projected_items {
208                    projected_item.draw(tile);
209                }
210
211                // Draw the hour sign and number
212                HOURS_SIGN_BITMAP
213                    .at(hours_top_left)
214                    .draw_masked(&HOURS_SIGN_MASK, tile)
215                    .unwrap_infallible();
216                draw_centered_sign_value(
217                    tile,
218                    hours_top_left,
219                    HOURS_SIGN_VALUE_CENTER,
220                    hour_12 as u32,
221                );
222
223                // Draw the minute sign and number.
224                MINUTE_SIGN_BITMAP
225                    .at(minute_top_left)
226                    .draw_masked(&MINUTE_SIGN_MASK, tile)
227                    .unwrap_infallible();
228                draw_centered_sign_value(
229                    tile,
230                    minute_top_left,
231                    MINUTE_SIGN_VALUE_CENTER,
232                    minute as u32,
233                );
234            })
235            .await
236            .map_err(Error::Flush)?;
237    }
238}
239
240/// Draw the skeleton-clock screen *before* the time is known: the status line
241/// reads `WiFi: --` / `--:--:-- --`, and the clock-face background_bitmap is shown with
242/// no figure or placards. Call this as early as possible (right after the display
243/// is initialized) so the user sees the framed clock immediately; the per-tick
244/// [`run`] loop then overwrites the WiFi text, time and figure as they
245/// become available.
246pub async fn splash<CydDisplayDevice>(
247    display: &mut CydDisplayDevice,
248) -> Result<(), Error<CydDisplayDevice::Error>>
249where
250    CydDisplayDevice: CydDisplay,
251{
252    display
253        .frame_mut(WIFI_STATUS_RECTANGLE)
254        .write_text("WiFi: --")
255        .flush()
256        .await
257        .map_err(Error::Flush)?;
258
259    display
260        .frame_mut(TIME_RECTANGLE)
261        .write_text("--:--:-- --")
262        .flush()
263        .await
264        .map_err(Error::Flush)?;
265
266    display
267        .for_each_tile(FIGURE_TILE_GRID, |frame| {
268            BACKGROUND_BITMAP.draw(frame).unwrap_infallible();
269        })
270        .await
271        .map_err(Error::Flush)?;
272
273    Ok(())
274}
275
276/// Actions requested by the Skeleton Clock's physical BOOT button.
277#[derive(Clone, Copy, Debug, Eq, PartialEq)]
278pub enum Exit {
279    /// Return to Wi-Fi setup before resuming the clock.
280    ResetWifi,
281}
282
283/// Error from the generic skeleton-clock loop, generic over the surface's flush
284/// error `FlushError`.
285///
286/// The device's flush error `FlushError` and the overflow value are converted
287/// explicitly with `.map_err(...)` at the call site: a blanket `From<FlushError>` would
288/// be greedy enough to collide with that concrete `From` under coherence.
289#[derive(Debug, derive_more::From)]
290pub enum Error<FlushError> {
291    /// A runtime linkage parameter was invalid.
292    Linkage(LinkageError),
293    /// Flushing a frame to the display failed.
294    #[from(ignore)]
295    Flush(FlushError),
296    /// The projected-items scratch buffer was smaller than the linkage draw-item count.
297    #[from(ignore)]
298    VecOverflow(DrawItem),
299}
300
301// ── Clock time ────────────────────────────────────────────────────────────────
302
303/// Format a 12-hour clock string with AM/PM. The hour is space-padded to two
304/// characters (e.g. " 5:04:32 PM" or "12:04:32 PM") so the colon stays aligned,
305/// but the string starts at the band's left edge with no leading spaces.
306fn text_12h(local_time: &OffsetDateTime) -> heapless::String<24> {
307    let (hour_12, minute, second) = h12_m_s(local_time);
308    let suffix = if local_time.hour() % 24 < 12 {
309        "AM"
310    } else {
311        "PM"
312    };
313    // The hour is space-padded to two characters (so " 5:04:32 PM" lines up with
314    // "12:04:32 PM"), but the string starts at the left edge with no extra leading
315    // spaces.
316    let mut text = heapless::String::new();
317    fmt::write(
318        &mut text,
319        format_args!("{hour_12:>2}:{minute:02}:{second:02} {suffix}"),
320    )
321    .expect("clock string fits in 24 bytes");
322    text
323}
324
325/// Format a 24-hour `HH:MM:SS` clock string.
326fn text_24h(local_time: &OffsetDateTime) -> heapless::String<9> {
327    let mut text = heapless::String::new();
328    fmt::write(
329        &mut text,
330        format_args!(
331            "{:02}:{:02}:{:02}",
332            local_time.hour(),
333            local_time.minute(),
334            local_time.second()
335        ),
336    )
337    .expect("clock string fits in 9 bytes");
338    text
339}
340
341fn linkage_params(local_time: &OffsetDateTime) -> [f32; 3] {
342    // Parameters are positional and depend on the order in the original `.lb.rs`.
343
344    // Index of each clock hand's param: the head shows seconds, the right arm
345    // minutes, the left arm hours.
346    const SECOND_INDEX: usize = 0;
347    const MINUTE_INDEX: usize = 1;
348    const HOUR_INDEX: usize = 2;
349
350    // Each param's range spans this many full turns, read straight from the linkage,
351    // so one clock turn maps to 1 / span of the normalized param.
352    const SECOND_SPAN_TURNS: f32 = param_span_turns(SECOND_INDEX);
353    const MINUTE_SPAN_TURNS: f32 = param_span_turns(MINUTE_INDEX);
354    const HOUR_SPAN_TURNS: f32 = param_span_turns(HOUR_INDEX);
355
356    // Check that everything is as expected.
357    const _: () = {
358        assert_param_name(SECOND_INDEX, "head_yrotation");
359        assert_param_name(MINUTE_INDEX, "r_shldr_zrotation");
360        assert_param_name(HOUR_INDEX, "l_shldr_zrotation");
361        assert!(SECOND_SPAN_TURNS == 4.0);
362        assert!(MINUTE_SPAN_TURNS == 4.0);
363        assert!(HOUR_SPAN_TURNS == 4.0);
364    };
365
366    // Calibration: what param value in 0..1 represents 12:00:00?
367    const HEAD_AT_12_PARAM: f32 = 0.5;
368    const RIGHT_ARM_AT_12_PARAM: f32 = 0.4375;
369    const LEFT_ARM_AT_12_PARAM: f32 = 0.5625;
370
371    // Find the fraction of a turn for each hand.
372    let seconds_turn = local_time.second() as f32 / 60.0;
373    let minutes_turn = (local_time.minute() as f32 + seconds_turn) / 60.0;
374    let hours_turn = ((local_time.hour() % 12) as f32 + minutes_turn) / 12.0;
375
376    // Set each 0.0 to 1.0 parameter in the correct order.
377    from_fn(|index| match index {
378        SECOND_INDEX => wrap_unit(HEAD_AT_12_PARAM + seconds_turn / SECOND_SPAN_TURNS),
379        MINUTE_INDEX => wrap_unit(RIGHT_ARM_AT_12_PARAM + minutes_turn / MINUTE_SPAN_TURNS),
380        HOUR_INDEX => wrap_unit(LEFT_ARM_AT_12_PARAM + hours_turn / HOUR_SPAN_TURNS),
381        _ => unreachable!(),
382    })
383}
384
385fn wrap_unit(value: f32) -> f32 {
386    let mut value = value;
387    while value >= 1.0 {
388        value -= 1.0;
389    }
390    while value < 0.0 {
391        value += 1.0;
392    }
393    value
394}
395
396/// Compile-time assert that `LINKAGE`'s param `index` has the expected name.
397const fn assert_param_name(index: usize, name: &str) {
398    assert!(str_eq(LINKAGE.view().param(index).name(), name));
399}
400
401/// The span of `LINKAGE`'s param `index`, in full turns (1 turn = 360°), read from
402/// the linkage's stored range.
403const fn param_span_turns(index: usize) -> f32 {
404    use core::f32::consts::TAU;
405    let (low, high) = LINKAGE.view().scan_param_range(index);
406    (high - low) / TAU
407}
408
409/// Const string equality, for the compile-time param-order assert in `linkage_params`.
410const fn str_eq(left: &str, right: &str) -> bool {
411    let (left, right) = (left.as_bytes(), right.as_bytes());
412    if left.len() != right.len() {
413        return false;
414    }
415    let mut i = 0;
416    while i < left.len() {
417        if left[i] != right[i] {
418            return false;
419        }
420        i += 1;
421    }
422    true
423}
424
425// ── Skeleton-clock-specific overlay drawing ──────────────────────────────────
426
427// All overlay drawing happens against a `DrawTarget` whose coordinates are in
428// figure-rectangle space; tiled callbacks replay screen coordinates and subtract the shared
429// figure-rectangle tile top-left so these functions never need to know they are
430// rendering into a tile.
431
432/// Draw a short string centered (both axes) on `center`.
433fn draw_centered_text<D>(
434    target: &mut D,
435    text: &str,
436    center: Point,
437    font: &'static MonoFont<'static>,
438    color: Rgb565,
439) where
440    D: DrawTarget<Color = Rgb565, Error = Infallible>,
441{
442    let text_style = TextStyleBuilder::new()
443        .alignment(Alignment::Center)
444        .baseline(Baseline::Middle)
445        .build();
446    Text::with_text_style(text, center, MonoTextStyle::new(font, color), text_style)
447        .draw(target)
448        .unwrap_infallible();
449}
450
451/// Overlay a two-digit value onto a blitted sign bitmap, centered in the open
452/// area of the sign body above its baked-in label. `sign_top_left` is the screen
453/// point where the bitmap's top-left was drawn, and `value_center` is the value's
454/// center relative to that top-left (e.g. [`HOURS_SIGN_VALUE_CENTER`] or
455/// [`MINUTE_SIGN_VALUE_CENTER`]).
456fn draw_centered_sign_value<D>(
457    target: &mut D,
458    sign_top_left: Point,
459    value_center: Point,
460    number: u32,
461) where
462    D: DrawTarget<Color = Rgb565, Error = Infallible>,
463{
464    let mut value_text = heapless::String::<4>::new();
465    fmt::write(&mut value_text, format_args!("{:02}", number % 100))
466        .expect("two-digit sign value fits in 4 bytes");
467    draw_centered_text(
468        target,
469        &value_text,
470        sign_top_left + value_center,
471        &FONT_10X20,
472        Rgb565::from(PLACARD_TEXT_COLOR),
473    );
474}
475
476// ── Errors ────────────────────────────────────────────────────────────────────
477
478#[cfg(test)]
479mod tests {
480    use core::cell::Cell;
481
482    use device_envoy_core::button::{__ButtonMonitor, Button};
483    use device_envoy_core::clock_sync::{ClockSync, ClockSyncTick, UnixSeconds};
484    use device_envoy_core::memory::{CydMemory, assert_framebuffer_matches_expected_png};
485    use futures_executor::block_on;
486    use time::OffsetDateTime;
487
488    use super::{BACKGROUND_COLOR, Exit, FOREGROUND_COLOR, ORIENTATION, TOP_FONT, run};
489
490    /// A `ClockSync` test double that ticks instantly with a fixed time,
491    /// rather than waiting on real NTP/timer infrastructure.
492    struct FixedClockSync {
493        local_time: OffsetDateTime,
494    }
495
496    impl ClockSync for FixedClockSync {
497        async fn wait_for_tick(&self) -> ClockSyncTick {
498            ClockSyncTick {
499                local_time: self.local_time,
500                since_last_sync: embassy_time::Duration::from_secs(0),
501            }
502        }
503
504        fn now_local(&self) -> OffsetDateTime {
505            self.local_time
506        }
507
508        fn set_offset_minutes(&self, _minutes: i32) {}
509
510        fn offset_minutes(&self) -> i32 {
511            0
512        }
513
514        fn set_tick_interval(&self, _interval: Option<embassy_time::Duration>) {}
515
516        fn set_speed(&self, _speed_multiplier: f32) {}
517
518        fn set_utc_time(&self, _unix_seconds: UnixSeconds) {}
519    }
520
521    struct ImmediateButton;
522
523    impl __ButtonMonitor for ImmediateButton {
524        fn is_pressed_raw(&self) -> bool {
525            false
526        }
527
528        async fn wait_until_pressed_state(&mut self, _pressed: bool) {}
529    }
530
531    impl Button for ImmediateButton {
532        async fn wait_for_press(&mut self) {}
533    }
534
535    #[test]
536    fn boot_requests_wifi_reset_before_rendering_the_next_tick() {
537        let memory_cyd = CydMemory::new(
538            ORIENTATION.size(),
539            BACKGROUND_COLOR,
540            FOREGROUND_COLOR,
541            &TOP_FONT,
542        );
543        let clock_sync = FixedClockSync {
544            local_time: OffsetDateTime::from_unix_timestamp(1_700_003_415)
545                .expect("valid fixed timestamp"),
546        };
547        let mut button = ImmediateButton;
548
549        let result = {
550            let mut display = memory_cyd.display();
551            block_on(run(&mut display, &clock_sync, &mut button))
552        };
553
554        assert_eq!(
555            result.expect("BOOT should be a typed exit"),
556            Exit::ResetWifi
557        );
558    }
559
560    #[test]
561    fn boot_requests_wifi_reset_after_a_rendered_tick() {
562        let mut memory_cyd = CydMemory::new(
563            ORIENTATION.size(),
564            BACKGROUND_COLOR,
565            FOREGROUND_COLOR,
566            &TOP_FONT,
567        );
568        memory_cyd.set_frame_budget(100);
569        let clock_sync = OneTickClockSync {
570            local_time: OffsetDateTime::from_unix_timestamp(1_700_003_415)
571                .expect("valid fixed timestamp"),
572            ticks: Cell::new(0),
573        };
574        let mut button = AfterTickButton {
575            waits: Cell::new(0),
576        };
577
578        let result = {
579            let mut display = memory_cyd.display();
580            block_on(run(&mut display, &clock_sync, &mut button))
581        };
582
583        assert_eq!(
584            result.expect("BOOT should exit after a rendered tick"),
585            Exit::ResetWifi
586        );
587        assert!(memory_cyd.flush_count() > 0);
588    }
589
590    // 1 flush for the digital time strip + 9 flushes for the 3x3 FIGURE_TILE_GRID
591    // = one complete rendered frame.
592    const ONE_COMPLETE_FRAME_BUDGET: usize = 10;
593
594    #[test]
595    fn skeleton_clock_renders_expected_frame() {
596        let mut memory_cyd = CydMemory::new(
597            ORIENTATION.size(),
598            BACKGROUND_COLOR,
599            FOREGROUND_COLOR,
600            &TOP_FONT,
601        );
602        memory_cyd.set_frame_budget(ONE_COMPLETE_FRAME_BUDGET);
603        let clock_sync = FixedClockSync {
604            local_time: OffsetDateTime::from_unix_timestamp(1_700_003_415)
605                .expect("valid fixed timestamp"),
606        };
607        let mut memory_button = NeverButton;
608
609        let skeleton_clock_result = {
610            let mut display = memory_cyd.display();
611            block_on(run(&mut display, &clock_sync, &mut memory_button))
612        };
613        skeleton_clock_result.expect_err("the free-running loop should stop at the frame budget");
614
615        assert_framebuffer_matches_expected_png(
616            &memory_cyd,
617            env!("CARGO_MANIFEST_DIR"),
618            "skeleton_clock.png",
619        )
620        .expect("rendered frame should match the golden image");
621    }
622
623    struct NeverButton;
624
625    impl __ButtonMonitor for NeverButton {
626        fn is_pressed_raw(&self) -> bool {
627            false
628        }
629
630        async fn wait_until_pressed_state(&mut self, _pressed: bool) {}
631    }
632
633    impl Button for NeverButton {
634        async fn wait_for_press(&mut self) {
635            core::future::pending().await
636        }
637    }
638
639    struct AfterTickButton {
640        waits: Cell<u8>,
641    }
642
643    impl __ButtonMonitor for AfterTickButton {
644        fn is_pressed_raw(&self) -> bool {
645            false
646        }
647
648        async fn wait_until_pressed_state(&mut self, _pressed: bool) {}
649    }
650
651    impl Button for AfterTickButton {
652        async fn wait_for_press(&mut self) {
653            let wait_number = self.waits.get();
654            self.waits.set(wait_number + 1);
655            if wait_number == 0 {
656                core::future::pending().await
657            }
658        }
659    }
660
661    struct OneTickClockSync {
662        local_time: OffsetDateTime,
663        ticks: Cell<u8>,
664    }
665
666    impl ClockSync for OneTickClockSync {
667        async fn wait_for_tick(&self) -> ClockSyncTick {
668            if self.ticks.replace(1) == 0 {
669                ClockSyncTick {
670                    local_time: self.local_time,
671                    since_last_sync: embassy_time::Duration::from_secs(0),
672                }
673            } else {
674                core::future::pending().await
675            }
676        }
677
678        fn now_local(&self) -> OffsetDateTime {
679            self.local_time
680        }
681
682        fn set_offset_minutes(&self, _minutes: i32) {}
683
684        fn offset_minutes(&self) -> i32 {
685            0
686        }
687
688        fn set_tick_interval(&self, _interval: Option<embassy_time::Duration>) {}
689
690        fn set_speed(&self, _speed_multiplier: f32) {}
691
692        fn set_utc_time(&self, _unix_seconds: UnixSeconds) {}
693    }
694}