embedded_wg_display/renderer/
mod.rs1use 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
27const 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
35const 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 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 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 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 loop_time = Instant::now();
144 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 if let Some(loop_delay) = RENDER_TICK_MS.checked_sub(loop_time.elapsed()) {
159 Timer::after(loop_delay).await;
160 }
161 }
162 }
163
164 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 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 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 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 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 if y >= DISPLAY_HEIGHT as i32 {
253 break;
254 }
255
256 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 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 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 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}