Skip to main content

linkage_blaze/examples/
clock.rs

1//! A clock display example driven by a parameterized linkage.
2//!
3//! The public entry points are platform-neutral; a platform launcher supplies
4//! the Device Envoy display and touch implementations.
5
6use core::{fmt, iter};
7
8use crate::{Error as LinkageError, linkage_file, render::Projection};
9use device_envoy_core::{
10    UnwrapInfallible,
11    button::Button,
12    clock_sync::{ClockSync, h12_m_s},
13    cyd::{
14        CydDisplay,
15        display::{
16            CydFrame, DrawItem, Image565Fixed, Image565View, Orientation, tga,
17            tiling::max_rectangle_pixel_count,
18        },
19    },
20};
21use embassy_futures::select::{Either, select};
22use embedded_graphics::{
23    Drawable,
24    mono_font::{MonoFont, MonoTextStyle, ascii::FONT_6X10},
25    pixelcolor::Rgb565,
26    pixelcolor::Rgb888,
27    prelude::{Point, Size},
28    primitives::Rectangle,
29    text::{Alignment, Baseline, Text, TextStyle, TextStyleBuilder},
30};
31use log::info;
32use profont::PROFONT_18_POINT;
33use time::OffsetDateTime;
34
35// ── Public constants ────────────────────────────────────────────────────────────────
36
37/// Near-black blue clock background.
38pub const BACKGROUND_COLOR: Rgb888 = Rgb888::new(3, 7, 14);
39/// Dim-gold clock foreground.
40pub const FOREGROUND_COLOR: Rgb888 = Rgb888::new(210, 160, 80);
41/// Display orientation used by the clock renderer.
42pub const ORIENTATION: Orientation = Orientation::Landscape;
43/// Font used for Wi-Fi status text.
44pub const WIFI_STATUS_FONT: MonoFont<'static> = FONT_6X10;
45/// Rectangle reserved for Wi-Fi status text.
46pub const WIFI_STATUS_RECTANGLE: Rectangle = Rectangle::new(Point::new(256, 5), Size::new(62, 10));
47/// Maximum number of pixels drawn in one clock frame.
48pub const MAX_FRAME_PIXEL_COUNT: usize =
49    max_rectangle_pixel_count(WIFI_STATUS_RECTANGLE, TIME_RECTANGLE);
50
51// ── Private constants ─────────────────────────────────────────────────────────
52
53const TIME_RECTANGLE: Rectangle = Rectangle::new(Point::new(55, 0), Size::new(200, 22));
54const TIME_COLOR: Rgb888 = Rgb888::new(255, 218, 118); // pale gold (255, 218, 118)
55const TIME_FONT: MonoFont<'static> = PROFONT_18_POINT;
56const TIME_TEXT_STYLE: TextStyle = TextStyleBuilder::new()
57    .alignment(Alignment::Center)
58    .baseline(Baseline::Top)
59    .build();
60const TIME_TEXT_CAPACITY: usize = 16;
61const TIME_TEXT_TOP_PADDING: i32 = -1;
62
63const CLOCK_BOUNDS: Rectangle = Rectangle::new(Point::new(50, 20), Size::new(220, 220));
64const BACKGROUND_BITMAP: Image565Fixed<320, 240, { 320 * 240 }> =
65    tga!("../assets/astronomy_window_background.tga").to_565();
66const BACKGROUND_BITMAP_VIEW: Image565View = BACKGROUND_BITMAP.view();
67const PROJECTION: Projection = Projection::top_orthographic(
68    Point::new(160, 130), // target origin
69    1.375,                // scale
70);
71const CLOCK_BACKGROUND_VIEW: Image565View = BACKGROUND_BITMAP.view_rect(CLOCK_BOUNDS);
72const CLOCK_BACKGROUND_BITMAP: DrawItem = DrawItem::Bitmap {
73    view: CLOCK_BACKGROUND_VIEW,
74    top_left: CLOCK_BOUNDS.top_left,
75};
76linkage_file! {
77    clock_linkage {
78        file: "../assets/examples/clock.lb.rs",
79    }
80}
81const LINKAGE: clock_linkage::View = clock_linkage::view();
82
83/// Run the clock render loop until the physical BOOT button requests a Wi-Fi
84/// reset, driven by `clock_sync` ticks and drawn onto `cyd`.
85pub async fn run<CydDisplayDevice, ClockSyncDevice>(
86    display: &mut CydDisplayDevice,
87    clock_sync: &ClockSyncDevice,
88    button: &mut impl Button,
89) -> Result<Exit, Error<CydDisplayDevice::Error>>
90where
91    CydDisplayDevice: CydDisplay,
92    ClockSyncDevice: ClockSync,
93{
94    let background565 = Rgb565::from(BACKGROUND_COLOR);
95    let time_color = Rgb565::from(TIME_COLOR);
96
97    loop {
98        // ── Wait for a tick and get the time. ────────────────────────────────────────
99        let tick = match select(button.wait_for_press(), clock_sync.wait_for_tick()).await {
100            Either::First(()) => return Ok(Exit::ResetWifi),
101            Either::Second(tick) => tick,
102        };
103        let local_time = &tick.local_time;
104        let time_text = text_12h(local_time)?;
105        info!("tick {}", time_text.as_str());
106
107        // ── Render the digital time strip (using embedded graphics methods). ─────────
108        let mut time_frame = display.frame_mut(TIME_RECTANGLE);
109        time_frame.fill(background565);
110        Text::with_text_style(
111            time_text.as_str(),
112            TIME_RECTANGLE.top_left
113                + Point::new(TIME_RECTANGLE.size.width as i32 / 2, TIME_TEXT_TOP_PADDING),
114            MonoTextStyle::new(&TIME_FONT, time_color),
115            TIME_TEXT_STYLE,
116        )
117        .draw(&mut time_frame)
118        .unwrap_infallible();
119        time_frame.flush().await.map_err(Error::Flush)?;
120        drop(time_frame);
121
122        // ── Stream the pixels of the updated clock ────────────────────────────────────────
123
124        // Compute the time-dependent linkage parameters, then project the clock's
125        // 3D draw items into pixel-space 2D draw items.
126        let params = linkage_params(local_time);
127        let draw_items_2d = LINKAGE
128            .draw_items_3d(&params)?
129            .map(|draw_item_3d| draw_item_3d.project(&PROJECTION));
130
131        // Stream the pixels row-major straight to the display with no frame or
132        // tile buffer, with the background_bitmap as the first pixel source.
133        display
134            .draw_items::<{ 1 + LINKAGE.draw_item_3d_count() }>(
135                CLOCK_BOUNDS,
136                background565, // color, but will be overridden by the background_bitmap
137                iter::once(CLOCK_BACKGROUND_BITMAP).chain(draw_items_2d),
138            )
139            .map_err(Error::Flush)?;
140    }
141}
142
143/// Draw the static full-screen clock background_bitmap.
144pub async fn splash<CydDisplayDevice>(
145    display: &mut CydDisplayDevice,
146) -> Result<(), Error<CydDisplayDevice::Error>>
147where
148    CydDisplayDevice: CydDisplay,
149{
150    display
151        .fill_contiguous_full(BACKGROUND_BITMAP_VIEW.rgb565_iter())
152        .map_err(Error::Flush)?;
153    Ok(())
154}
155
156/// Actions requested by the Clock's physical BOOT button.
157#[derive(Clone, Copy, Debug, Eq, PartialEq)]
158pub enum Exit {
159    /// Return to Wi-Fi setup before resuming the clock.
160    ResetWifi,
161}
162
163/// Error from the generic clock loop, generic over the surface's flush error `FlushError`.
164///
165/// Both variants are converted explicitly at the call site (`.map_err(...)`),
166/// the same flush-error convention as
167/// [`skeleton_clock::Error`](crate::examples::skeleton_clock::Error).
168#[derive(Debug, derive_more::From)]
169pub enum Error<FlushError> {
170    /// A runtime linkage parameter was invalid.
171    Linkage(LinkageError),
172    /// Formatting the time string failed.
173    Text(fmt::Error),
174    /// Flushing a frame to the display failed.
175    #[from(ignore)]
176    Flush(FlushError),
177}
178
179// ── Private helpers ───────────────────────────────────────────────────────────
180
181// ── Clock time ──────────────────────────────────────────────────────────────────
182
183/// Format a 12-hour clock string with AM/PM.
184fn text_12h(
185    local_time: &OffsetDateTime,
186) -> Result<heapless::String<TIME_TEXT_CAPACITY>, fmt::Error> {
187    let (hour_12, minute, _) = h12_m_s(local_time);
188    let meridiem = if local_time.hour() < 12 { "AM" } else { "PM" };
189    let mut text = heapless::String::new();
190    fmt::write(&mut text, format_args!("{hour_12}:{minute:02} {meridiem}"))?;
191    Ok(text)
192}
193
194fn linkage_params(local_time: &OffsetDateTime) -> [f32; 2] {
195    let (hour_12, minute, second) = h12_m_s(local_time);
196    let second_turn = second as f32 / 60.0;
197    let minute_turn = (minute as f32 + second_turn) / 60.0;
198    let hour = ((hour_12 % 12) as f32 + minute_turn) / 12.0;
199    let face_spin = (((second % 20) as f32) / 20.0 + 0.5) % 1.0;
200    [hour, face_spin]
201}
202
203#[cfg(test)]
204mod tests {
205    use core::cell::Cell;
206
207    use device_envoy_core::button::{__ButtonMonitor, Button};
208    use device_envoy_core::clock_sync::{ClockSync, ClockSyncTick, UnixSeconds};
209    use device_envoy_core::cyd::{CydDisplay, display::CydFrame};
210    use device_envoy_core::memory::{CydMemory, assert_framebuffer_matches_expected_png};
211    use futures_executor::block_on;
212    use time::OffsetDateTime;
213
214    use super::{
215        BACKGROUND_COLOR, Exit, FOREGROUND_COLOR, ORIENTATION, WIFI_STATUS_FONT,
216        WIFI_STATUS_RECTANGLE, run, splash,
217    };
218
219    /// A `ClockSync` test double that ticks instantly with a fixed time,
220    /// rather than waiting on real NTP/timer infrastructure.
221    struct FixedClockSync {
222        local_time: OffsetDateTime,
223    }
224
225    impl ClockSync for FixedClockSync {
226        async fn wait_for_tick(&self) -> ClockSyncTick {
227            ClockSyncTick {
228                local_time: self.local_time,
229                since_last_sync: embassy_time::Duration::from_secs(0),
230            }
231        }
232
233        fn now_local(&self) -> OffsetDateTime {
234            self.local_time
235        }
236
237        fn set_offset_minutes(&self, _minutes: i32) {}
238
239        fn offset_minutes(&self) -> i32 {
240            0
241        }
242
243        fn set_tick_interval(&self, _interval: Option<embassy_time::Duration>) {}
244
245        fn set_speed(&self, _speed_multiplier: f32) {}
246
247        fn set_utc_time(&self, _unix_seconds: UnixSeconds) {}
248    }
249
250    struct ImmediateButton;
251
252    impl __ButtonMonitor for ImmediateButton {
253        fn is_pressed_raw(&self) -> bool {
254            false
255        }
256
257        async fn wait_until_pressed_state(&mut self, _pressed: bool) {}
258    }
259
260    impl Button for ImmediateButton {
261        async fn wait_for_press(&mut self) {}
262    }
263
264    #[test]
265    fn boot_requests_wifi_reset_before_rendering_the_next_tick() {
266        let memory_cyd = CydMemory::new(
267            ORIENTATION.size(),
268            BACKGROUND_COLOR,
269            FOREGROUND_COLOR,
270            &WIFI_STATUS_FONT,
271        );
272        let clock_sync = FixedClockSync {
273            local_time: OffsetDateTime::from_unix_timestamp(1_700_003_415)
274                .expect("valid fixed timestamp"),
275        };
276        let mut button = ImmediateButton;
277
278        let result = {
279            let mut display = memory_cyd.display();
280            block_on(run(&mut display, &clock_sync, &mut button))
281        };
282
283        assert_eq!(
284            result.expect("BOOT should be a typed exit"),
285            Exit::ResetWifi
286        );
287    }
288
289    #[test]
290    fn boot_requests_wifi_reset_after_a_rendered_tick() {
291        let mut memory_cyd = CydMemory::new(
292            ORIENTATION.size(),
293            BACKGROUND_COLOR,
294            FOREGROUND_COLOR,
295            &WIFI_STATUS_FONT,
296        );
297        memory_cyd.set_frame_budget(100);
298        let clock_sync = OneTickClockSync {
299            local_time: OffsetDateTime::from_unix_timestamp(1_700_003_415)
300                .expect("valid fixed timestamp"),
301            ticks: Cell::new(0),
302        };
303        let mut button = AfterTickButton {
304            waits: Cell::new(0),
305        };
306
307        let result = {
308            let mut display = memory_cyd.display();
309            block_on(run(&mut display, &clock_sync, &mut button))
310        };
311
312        assert_eq!(
313            result.expect("BOOT should exit after a rendered tick"),
314            Exit::ResetWifi
315        );
316        assert!(memory_cyd.flush_count() > 0);
317    }
318
319    #[test]
320    fn clock_renders_expected_frame() {
321        let mut memory_cyd = CydMemory::new(
322            ORIENTATION.size(),
323            BACKGROUND_COLOR,
324            FOREGROUND_COLOR,
325            &WIFI_STATUS_FONT,
326        );
327        memory_cyd.set_frame_budget(3);
328        let clock_sync = FixedClockSync {
329            local_time: OffsetDateTime::from_unix_timestamp(1_700_003_415)
330                .expect("valid fixed timestamp"),
331        };
332        let mut memory_button = NeverButton;
333
334        {
335            let mut display = memory_cyd.display();
336            block_on(splash(&mut display))
337                .expect("clock splash should draw the static background_bitmap");
338            block_on(
339                display
340                    .frame_mut(WIFI_STATUS_RECTANGLE)
341                    .clear()
342                    .write_text("WiFi: OK")
343                    .flush(),
344            )
345            .expect("wifi status frame should flush during setup");
346        }
347
348        let clock_result = {
349            let mut display = memory_cyd.display();
350            block_on(run(&mut display, &clock_sync, &mut memory_button))
351        };
352        clock_result.expect_err("the free-running loop should stop at the frame budget");
353
354        assert_framebuffer_matches_expected_png(
355            &memory_cyd,
356            env!("CARGO_MANIFEST_DIR"),
357            "clock.png",
358        )
359        .expect("rendered frame should match the golden image");
360    }
361
362    struct NeverButton;
363
364    impl __ButtonMonitor for NeverButton {
365        fn is_pressed_raw(&self) -> bool {
366            false
367        }
368
369        async fn wait_until_pressed_state(&mut self, _pressed: bool) {}
370    }
371
372    impl Button for NeverButton {
373        async fn wait_for_press(&mut self) {
374            core::future::pending().await
375        }
376    }
377
378    struct AfterTickButton {
379        waits: Cell<u8>,
380    }
381
382    impl __ButtonMonitor for AfterTickButton {
383        fn is_pressed_raw(&self) -> bool {
384            false
385        }
386
387        async fn wait_until_pressed_state(&mut self, _pressed: bool) {}
388    }
389
390    impl Button for AfterTickButton {
391        async fn wait_for_press(&mut self) {
392            let wait_number = self.waits.get();
393            self.waits.set(wait_number + 1);
394            if wait_number == 0 {
395                core::future::pending().await
396            }
397        }
398    }
399
400    struct OneTickClockSync {
401        local_time: OffsetDateTime,
402        ticks: Cell<u8>,
403    }
404
405    impl ClockSync for OneTickClockSync {
406        async fn wait_for_tick(&self) -> ClockSyncTick {
407            if self.ticks.replace(1) == 0 {
408                ClockSyncTick {
409                    local_time: self.local_time,
410                    since_last_sync: embassy_time::Duration::from_secs(0),
411                }
412            } else {
413                core::future::pending().await
414            }
415        }
416
417        fn now_local(&self) -> OffsetDateTime {
418            self.local_time
419        }
420
421        fn set_offset_minutes(&self, _minutes: i32) {}
422
423        fn offset_minutes(&self) -> i32 {
424            0
425        }
426
427        fn set_tick_interval(&self, _interval: Option<embassy_time::Duration>) {}
428
429        fn set_speed(&self, _speed_multiplier: f32) {}
430
431        fn set_utc_time(&self, _unix_seconds: UnixSeconds) {}
432    }
433}