Skip to main content

embedded_wg_display/renderer/
mod.rs

1//! Widget rendering loop, executes widgets and displays their information on the screen.
2
3use alloc::boxed::Box;
4use alloc::string::{String, ToString};
5use alloc::vec::Vec;
6
7use common::models::SystemConfiguration;
8use defmt::{error, info};
9use embassy_time::{Duration, Instant, Timer};
10use embedded_graphics::Drawable;
11use embedded_graphics::draw_target::DrawTarget;
12use embedded_graphics::geometry::Size;
13use embedded_graphics::mono_font::MonoFont;
14use embedded_graphics::mono_font::MonoTextStyle;
15use embedded_graphics::mono_font::iso_8859_1::{FONT_8X13, FONT_9X18_BOLD};
16use embedded_graphics::pixelcolor::Rgb565;
17use embedded_graphics::prelude::{Point, RgbColor};
18use embedded_graphics::primitives::{Line, Primitive, PrimitiveStyle, Rectangle};
19use embedded_graphics::text::Text;
20use embedded_graphics_framebuf::FrameBuf;
21
22use alloc::format;
23
24use crate::runtime::Runtime;
25use crate::util::globals;
26
27/// Constant for the delay between widget update checks
28const RENDER_TICK_MS: Duration = Duration::from_millis(1000);
29const DISPLAY_WIDTH: u32 = 320;
30const DISPLAY_HEIGHT: u32 = 240;
31const DISPLAY_PIXELS: usize = (DISPLAY_WIDTH as usize) * (DISPLAY_HEIGHT as usize);
32const ACCENT_WIDTH: i32 = 3;
33const LEFT_PADDING: i32 = ACCENT_WIDTH + 5;
34
35// change to true to enable larger font, might be changed in the future to be configurable via web ui
36const USE_LARGE_FONT: bool = cfg!(feature = "large-font");
37
38fn active_font() -> &'static MonoFont<'static> {
39    if USE_LARGE_FONT {
40        &FONT_9X18_BOLD
41    } else {
42        &FONT_8X13
43    }
44}
45
46fn line_height() -> i32 {
47    if USE_LARGE_FONT { 20 } else { 14 }
48}
49
50fn header_height() -> i32 {
51    if USE_LARGE_FONT { 24 } else { 18 }
52}
53
54fn widget_gap() -> i32 {
55    if USE_LARGE_FONT { 8 } else { 6 }
56}
57
58fn display_width_chars() -> usize {
59    (DISPLAY_WIDTH as usize / active_font().character_size.width as usize).saturating_sub(1)
60}
61
62struct WasmWidget {
63    name: String,
64    config_json: String,
65    update_cycle_seconds: u32,
66    last_run: Option<Instant>,
67    last_output: String,
68}
69
70pub struct Renderer {
71    widgets: Vec<WasmWidget>,
72    framebuffer: Box<[Rgb565; DISPLAY_PIXELS]>,
73    background_color: Rgb565,
74    runtime: Runtime,
75    ip_address: String,
76}
77
78impl Renderer {
79    pub fn new() -> Self {
80        Self {
81            widgets: Vec::new(),
82            // TODO: maybe make static  or use vec!
83            framebuffer: Box::new([Rgb565::BLACK; DISPLAY_PIXELS]),
84            background_color: Rgb565::BLACK,
85            runtime: Runtime::new(),
86            ip_address: "IP unknown".to_string(),
87        }
88    }
89
90    /// Update the information stored in [Renderer::widgets] based on the provided [`SystemConfiguration`].
91    fn update_widget_information(&mut self, config: &SystemConfiguration) {
92        self.background_color = parse_background_color(config.background_color.as_str());
93        self.widgets = config
94            .widgets
95            .iter()
96            .map(|wc| WasmWidget {
97                name: wc.name.clone(),
98                config_json: wc.json_config.clone(),
99                update_cycle_seconds: if wc.update_cycle_seconds > 0 {
100                    wc.update_cycle_seconds
101                } else {
102                    1
103                },
104                last_run: None,
105                last_output: "-".to_string(),
106            })
107            .collect();
108    }
109
110    /// Renderer loop, stores copy of [`SystemConfiguration`] and updates it only if changes are detected via [`Storage::get_system_config_change`](crate::storage::Storage::get_system_config_change).
111    /// This function will never return and run indefinitly.
112    /// Runs on the seccond core due to [Runtime::run()](crate::runtime::Runtime::run()) being blocking due to Wasmtime's host functions.
113    /// See [`runtime::http_sync`](crate::runtime::http_sync) for details.
114    /// Only loads and runs widgets once their update cycle has passed.
115    pub async fn run(&mut self) {
116        self.ip_address = globals::with_storage(|storage| {
117            storage
118                .config_get("device_ip")
119                .unwrap_or_else(|_| "IP unknown".to_string())
120        })
121        .await;
122
123        let mut config = globals::with_storage(|storage| {
124            let config = storage.get_system_config();
125            match config {
126                Ok(config) => config,
127                Err(err) => {
128                    error!("Failed to get system config: {:?}", err);
129                    SystemConfiguration::default()
130                }
131            }
132        })
133        .await;
134
135        self.update_widget_information(&config);
136        info!("Renderer initialized {} widgets", self.widgets.len());
137        self.render_layout().await;
138
139        let mut loop_time: Instant;
140
141        loop {
142            // set loop time
143            loop_time = Instant::now();
144            // update config if changes were made in the web ui
145            if let Some(new_config) =
146                globals::with_storage(|storage| storage.get_system_config_change()).await
147            {
148                config = new_config;
149                self.update_widget_information(&config);
150                info!("Renderer reloaded {} widgets", self.widgets.len());
151            }
152
153            self.update_widgets().await;
154            self.render_layout().await;
155
156            // wait for 1 second minus the elapes time in this loop
157            // will skip if loop took longer than 1 second
158            if let Some(loop_delay) = RENDER_TICK_MS.checked_sub(loop_time.elapsed()) {
159                Timer::after(loop_delay).await;
160            }
161        }
162    }
163
164    /// Checks if any widget needs to be run this cycle, runs them, and updates their last output.
165    async fn update_widgets(&mut self) {
166        let now = Instant::now();
167        for widget in &mut self.widgets {
168            let should_run = match widget.last_run {
169                None => true,
170                Some(last) => {
171                    now.duration_since(last)
172                        >= Duration::from_secs(u64::from(widget.update_cycle_seconds))
173                }
174            };
175            if !should_run {
176                continue;
177            }
178
179            widget.last_run = Some(now);
180
181            let widget_result = unsafe {
182                self.runtime
183                    .run_widget(widget.name.clone(), widget.config_json.clone())
184                    .await
185            };
186
187            widget.last_output = match widget_result {
188                Ok(Some(result)) => result.data,
189                Ok(None) => "No output".to_string(),
190                Err(err) => {
191                    error!(
192                        "Widget '{}' execution failed: {:?}",
193                        widget.name.as_str(),
194                        defmt::Debug2Format(&err)
195                    );
196                    "Widget execution failed".to_string()
197                }
198            };
199        }
200    }
201
202    /// Renders the screen layout, writes into a framebuffer to avoid screen flickering.
203    async fn render_layout(&mut self) {
204        let font = active_font();
205        let line_height = line_height();
206        let header_height = header_height();
207        let widget_gap = widget_gap();
208
209        let mut fb = FrameBuf::new(
210            self.framebuffer.as_mut(),
211            DISPLAY_WIDTH as usize,
212            DISPLAY_HEIGHT as usize,
213        );
214        let _ = fb.clear(self.background_color);
215
216        // header bar
217        Rectangle::new(
218            Point::new(0, 0),
219            Size::new(DISPLAY_WIDTH, header_height as u32),
220        )
221        .into_styled(PrimitiveStyle::with_fill(Rgb565::new(1, 8, 16)))
222        .draw(&mut fb)
223        .ok();
224
225        let header_style = MonoTextStyle::new(font, Rgb565::WHITE);
226        draw_text(
227            &mut fb,
228            &format!("WG Display  {}", self.ip_address),
229            4,
230            header_height - 4,
231            &header_style,
232        );
233
234        // divider
235        Line::new(
236            Point::new(0, header_height),
237            Point::new(DISPLAY_WIDTH as i32 - 1, header_height),
238        )
239        .into_styled(PrimitiveStyle::with_stroke(Rgb565::CYAN, 1))
240        .draw(&mut fb)
241        .ok();
242
243        // widgets
244        let name_style = MonoTextStyle::new(font, Rgb565::CYAN);
245        let output_style = MonoTextStyle::new(font, Rgb565::YELLOW);
246
247        let mut y = header_height + line_height + 2;
248        let widget_count = self.widgets.len();
249
250        for (i, widget) in self.widgets.iter().enumerate() {
251            // stop if no space left on screen
252            if y >= DISPLAY_HEIGHT as i32 {
253                break;
254            }
255
256            // accent bar
257            Rectangle::new(
258                Point::new(0, y - (line_height - 3)),
259                Size::new(ACCENT_WIDTH as u32, (line_height - 1) as u32),
260            )
261            .into_styled(PrimitiveStyle::with_fill(Rgb565::CYAN))
262            .draw(&mut fb)
263            .ok();
264
265            // widget name
266            draw_text(&mut fb, &widget.name, LEFT_PADDING, y, &name_style);
267            y += line_height;
268
269            if y >= DISPLAY_HEIGHT as i32 {
270                break;
271            }
272
273            // draw each output line of widget
274            for line in widget.last_output.lines() {
275                if y >= DISPLAY_HEIGHT as i32 {
276                    break;
277                }
278                draw_text(&mut fb, line, LEFT_PADDING, y, &output_style);
279                y += line_height;
280            }
281
282            // thin separator between widgets
283            if i + 1 < widget_count {
284                let sep_y = y - line_height + (line_height / 2 - 1);
285                if sep_y > header_height && sep_y < DISPLAY_HEIGHT as i32 {
286                    Line::new(
287                        Point::new(LEFT_PADDING, sep_y),
288                        Point::new(DISPLAY_WIDTH as i32 - LEFT_PADDING, sep_y),
289                    )
290                    .into_styled(PrimitiveStyle::with_stroke(Rgb565::new(4, 8, 4), 1))
291                    .draw(&mut fb)
292                    .ok();
293                }
294                y += widget_gap;
295            }
296        }
297
298        let pixel_iter = self.framebuffer.iter().copied();
299        globals::with_display(|display| {
300            let _ = display.display_mut().set_pixels(
301                0,
302                0,
303                (DISPLAY_WIDTH - 1) as u16,
304                (DISPLAY_HEIGHT - 1) as u16,
305                pixel_iter,
306            );
307        })
308        .await;
309    }
310}
311
312fn parse_background_color(color: &str) -> Rgb565 {
313    let hex = color.strip_prefix('#').unwrap_or(color);
314    if hex.len() != 6 {
315        return Rgb565::BLACK;
316    }
317
318    let r = u8::from_str_radix(&hex[0..2], 16).ok();
319    let g = u8::from_str_radix(&hex[2..4], 16).ok();
320    let b = u8::from_str_radix(&hex[4..6], 16).ok();
321
322    match (r, g, b) {
323        (Some(r), Some(g), Some(b)) => Rgb565::new(r >> 3, g >> 2, b >> 3),
324        _ => Rgb565::BLACK,
325    }
326}
327
328fn draw_text<T>(target: &mut T, text: &str, x: i32, y: i32, style: &MonoTextStyle<'_, Rgb565>)
329where
330    T: DrawTarget<Color = Rgb565>,
331{
332    let max_chars = display_width_chars();
333    let truncated = if text.len() > max_chars {
334        let mut s = text
335            .chars()
336            .take(max_chars.saturating_sub(3))
337            .collect::<String>();
338        s.push_str("...");
339        s
340    } else {
341        text.to_string()
342    };
343    let _ = Text::new(&truncated, Point::new(x, y), *style).draw(target);
344}