oled: rewrite the oled UI to have animations, and support a version of sleeping where the display is blank, at least

This commit is contained in:
2025-10-17 20:28:55 +02:00
parent 25a7dc7e18
commit 2dcdca0675
5 changed files with 358 additions and 118 deletions

View File

@@ -61,7 +61,7 @@ async fn main(spawner: Spawner) {
let timer1 = TimerGroup::new(peripherals.TIMG1);
let mut ui_wdt = timer1.wdt;
ui_wdt.set_timeout(esp_hal::timer::timg::MwdtStage::Stage0, esp_hal::time::Duration::from_secs(10));
ui_wdt.enable(); //FIXME: Re-enable UI watchdog once we have a brain task running
ui_wdt.enable();
let garage = BUS_GARAGE.init(Default::default());
@@ -104,19 +104,12 @@ async fn main(spawner: Spawner) {
use esp_hal::i2c::master::{Config, I2c};
let mut rst = Output::new(peripherals.GPIO21, esp_hal::gpio::Level::Low, OutputConfig::default());
Timer::after_millis(10).await;
rst.set_high();
Timer::after_millis(10).await;
rst.set_low();
Timer::after_millis(10).await;
rst.set_high();
let i2c = I2c::new(
peripherals.I2C0,
Config::default().with_frequency(Rate::from_khz(400))
).unwrap().with_scl(peripherals.GPIO18).with_sda(peripherals.GPIO17).into_async();
spawner.must_spawn(renderbug_embassy::tasks::oled::oled_task(i2c, garage.telemetry.dyn_subscriber().unwrap()));
spawner.must_spawn(renderbug_embassy::tasks::oled::oled_task(i2c, rst, garage.telemetry.dyn_subscriber().unwrap()));
}
#[cfg(feature="simulation")]

View File

@@ -1,15 +1,19 @@
use core::cell::RefCell;
use core::{cell::RefCell, f32::consts::PI};
use alloc::format;
use embassy_sync::pubsub::DynSubscriber;
use embassy_time::Timer;
use embedded_graphics::{image::Image, mono_font::{ascii::{FONT_6X10, FONT_10X20, FONT_9X18_BOLD}, MonoTextStyleBuilder}, pixelcolor::BinaryColor, prelude::*, primitives::{Line, PrimitiveStyleBuilder, Rectangle, StyledDrawable}, text::{Alignment, Baseline, Text}};
use esp_hal::{i2c::master::I2c, Async};
use esp_hal::{i2c::master::I2c, Async, gpio::Output};
use figments::{liber8tion::{interpolate::{ease_in_out_quad, scale8}, trig::{cos8, sin8}}, prelude::Fract8Ops};
use nalgebra::Vector2;
use ssd1306::{mode::DisplayConfigAsync, prelude::DisplayRotation, size::DisplaySize128x64, I2CDisplayInterface, Ssd1306Async};
use ssd1306::{mode::{BufferedGraphicsModeAsync, DisplayConfigAsync}, prelude::{DisplayRotation, I2CInterface}, size::DisplaySize128x64, I2CDisplayInterface, Ssd1306Async};
use log::*;
use embedded_graphics::mono_font::MonoTextStyle;
use embedded_graphics::primitives::PrimitiveStyle;
use nalgebra::ComplexField;
use crate::{backoff::Backoff, ego::engine::MotionState, events::{Notification, Prediction, Scene, SensorSource, Telemetry}};
use crate::{backoff::Backoff, ego::engine::MotionState, events::{Notification, Prediction, Scene, SensorSource, Telemetry}, tasks::ui};
mod images {
use embedded_graphics::{
@@ -29,15 +33,194 @@ struct OledUI {
imu_online: bool,
velocity: f32,
location: Vector2<f64>,
sleep: bool
sleep: bool,
}
struct UiPainter<'a>(&'a mut Ssd1306Async<I2CInterface<I2c<'static, Async>>, DisplaySize128x64, BufferedGraphicsModeAsync<DisplaySize128x64>>);
#[derive(Debug, Default, Clone, Copy)]
enum Screen {
#[default]
Blank,
Bootsplash,
Home,
Sleeping,
Waking
}
impl UiPainter<'_> {
pub async fn screen_transition(&mut self, from: Screen, to: Screen, state: &OledUI) {
warn!("Transitioning screens from {from:?} to {to:?}");
for pos in 0..16 {
self.blank();
// Draw the screen, then paint the swipe across it from left to right
self.draw_screen(from, 0, state);
Rectangle::new(Point::zero(), Size { width: figments::liber8tion::interpolate::ease_in_out_quad(pos*8) as u32, height: 64}).draw_styled(&DRAW_STYLE, self.0).unwrap();
self.commit().await;
}
for pos in 0..16 {
self.blank();
// Paint the next screen, then cover the right half with the swipe
self.draw_screen(to, 0, state);
let pos = figments::liber8tion::interpolate::ease_in_out_quad(pos*8) as u32;
Rectangle::new(Point::new(pos as i32, 0), Size { width: 128 - pos, height: 64}).draw_styled(&DRAW_STYLE, self.0).unwrap();
self.commit().await;
}
// Finally draw the untouched screen
self.blank();
self.draw_screen(to, 0, state);
self.commit().await;
}
pub fn draw_screen(&mut self, screen: Screen, frame: usize, ui_state: &OledUI) {
match screen {
Screen::Blank => (),
Screen::Bootsplash => {
Image::new(&images::BOOT_LOGO, Point::zero()).draw(self.0).unwrap();
const SPARKLE_COUNT: i32 = 8;
for n in 0..SPARKLE_COUNT {
let sparkle_center = Point::new(128u8.scale8(cos8(frame.wrapping_mul(n as usize))) as i32, 64u8.scale8(sin8(frame.wrapping_mul(n as usize) as u8)) as i32);
let offset = (frame / 2 % 32) as i32 - 16;
let rotation = PI * 2.0 * (sin8(frame) as f32 / 255.0);
let normal = Point::new((rotation.cos() * offset as f32) as i32, (rotation.sin() * offset as f32) as i32);
let cross_normal = Point::new((rotation.sin() * offset as f32) as i32, (rotation.cos() * offset as f32) as i32);
// Draw horizontal
Line::new(sparkle_center - normal, sparkle_center + normal)
.draw_styled(&SPARKLE_STYLE, self.0).unwrap();
// Draw vertical
Line::new(sparkle_center - cross_normal, sparkle_center + cross_normal)
.draw_styled(&SPARKLE_STYLE, self.0).unwrap();
}
},
Screen::Home => {
// Status bar
// Sensor indicators
let gps_img = if ui_state.gps_online {
&images::GPS_ON
} else {
&images::GPS_OFF
};
let imu_img = if ui_state.imu_online {
&images::IMU_ON
} else {
&images::IMU_OFF
};
Image::new(gps_img, Point::zero()).draw(self.0).unwrap();
Image::new(imu_img, Point::new((gps_img.size().width + 2) as i32, 0)).draw(self.0).unwrap();
#[cfg(feature="demo")]
Text::with_alignment("Demo", Point::new(128, 10), TEXT_STYLE, Alignment::Right)
.draw(self.0)
.unwrap();
#[cfg(feature="simulation")]
Text::with_alignment("Sim", Point::new(128, 10), TEXT_STYLE, Alignment::Right)
.draw(self.0)
.unwrap();
// Separates the status bar from the UI
Line::new(Point::new(0, 18), Point::new(128, 18)).draw_styled(&INACTIVE_STYLE, self.0).unwrap();
// Speed display at the top
Text::with_alignment(&format!("{}", ui_state.velocity), Point::new(128 / 2, 12), SPEED_STYLE, Alignment::Center)
.draw(self.0)
.unwrap();
// The main UI content
Image::new(&images::BIKE, Point::new((128 / 2 - images::BIKE.size().width / 2) as i32, 24)).draw(self.0).unwrap();
let headlight_img = if ui_state.headlight {
&images::HEADLIGHT_ON
} else {
&images::HEADLIGHT_OFF
};
let brakelight_img = if ui_state.brakelight {
&images::BRAKELIGHT_ON
} else {
&images::BRAKELIGHT_OFF
};
Image::new(headlight_img, Point::new(((128 / 2 - images::BIKE.size().width / 2) - 18) as i32, 28)).draw(self.0).unwrap();
Image::new(brakelight_img, Point::new(((128 / 2 + images::BIKE.size().width / 2) + 2) as i32, 28)).draw(self.0).unwrap();
// TODO: Replace the state texts with cute animations or smth
// Current prediction from the motion engine
Text::with_alignment(&format!("{:?}", ui_state.motion), Point::new(128 / 2, 64 - 3), TEXT_STYLE, Alignment::Center)
.draw(self.0)
.unwrap();
// Current scene in the UI
Text::with_alignment(&format!("{:?}", ui_state.scene), Point::new(128 / 2, 64 - 13), TEXT_STYLE, Alignment::Center)
.draw(self.0)
.unwrap();
},
Screen::Sleeping => {
Text::with_alignment("so eepy Zzz...", Point::new(128 / 2, 64 / 2), LOGO_STYLE, Alignment::Center)
.draw(self.0)
.unwrap();
},
Screen::Waking => {
Text::with_alignment("OwO hewwo again :D", Point::new(128 / 2, 64 / 2), LOGO_STYLE, Alignment::Center)
.draw(self.0)
.unwrap();
}
}
}
pub fn blank(&mut self) {
self.0.clear_buffer();
}
pub async fn commit(&mut self) {
self.0.flush().await.expect("Could not commit frame");
}
}
const SPARKLE_STYLE: PrimitiveStyle<BinaryColor> = PrimitiveStyleBuilder::new()
.stroke_color(BinaryColor::On)
.stroke_width(2)
.build();
const TEXT_STYLE: MonoTextStyle<BinaryColor> = MonoTextStyleBuilder::new()
.font(&FONT_6X10)
.text_color(BinaryColor::On)
.build();
const LOGO_STYLE: MonoTextStyle<BinaryColor> = MonoTextStyleBuilder::new()
.font(&FONT_9X18_BOLD)
.text_color(BinaryColor::On)
.build();
const SPEED_STYLE: MonoTextStyle<BinaryColor> = MonoTextStyleBuilder::new()
.font(&FONT_10X20)
.text_color(BinaryColor::On)
.build();
const DRAW_STYLE: PrimitiveStyle<BinaryColor> = PrimitiveStyleBuilder::new()
.fill_color(BinaryColor::On)
.build();
const INACTIVE_STYLE: PrimitiveStyle<BinaryColor> = PrimitiveStyleBuilder::new()
.fill_color(BinaryColor::Off)
.stroke_color(BinaryColor::On)
.stroke_width(2)
.build();
#[embassy_executor::task]
pub async fn oled_task(i2c: I2c<'static, Async>, mut events: DynSubscriber<'static, Telemetry>) {
pub async fn oled_task(i2c: I2c<'static, Async>, mut reset_pin: Output<'static>, mut events: DynSubscriber<'static, Telemetry>) {
let interface = RefCell::new(Some(I2CDisplayInterface::new(i2c)));
let mut ui_state = OledUI::default();
// initialize the display
let mut display = Backoff::from_secs(3).forever().attempt(async || {
info!("Setting up OLED display");
Timer::after_millis(10).await;
reset_pin.set_high();
Timer::after_millis(10).await;
reset_pin.set_low();
Timer::after_millis(10).await;
reset_pin.set_high();
let mut display = Ssd1306Async::new(interface.replace(None).unwrap(), DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
if let Err(e) = display.init().await {
@@ -52,119 +235,49 @@ pub async fn oled_task(i2c: I2c<'static, Async>, mut events: DynSubscriber<'sta
display.clear(BinaryColor::Off).unwrap();
display.flush().await.unwrap();
let text_style = MonoTextStyleBuilder::new()
.font(&FONT_6X10)
.text_color(BinaryColor::On)
.build();
let logo_style = MonoTextStyleBuilder::new()
.font(&FONT_9X18_BOLD)
.text_color(BinaryColor::On)
.build();
let speed_style = MonoTextStyleBuilder::new()
.font(&FONT_10X20)
.text_color(BinaryColor::On)
.build();
let draw_style = PrimitiveStyleBuilder::new()
.fill_color(BinaryColor::On)
.build();
let erase_style = PrimitiveStyleBuilder::new()
.fill_color(BinaryColor::Off)
.build();
let inactive_style = PrimitiveStyleBuilder::new()
.fill_color(BinaryColor::Off)
.stroke_color(BinaryColor::On)
.stroke_width(2)
.build();
let mut ui_state = OledUI::default();
info!("Running boot splash animation");
for pos in 0..16 {
Rectangle::new(Point::zero(), Size { width: figments::liber8tion::interpolate::ease_in_out_quad(pos*8) as u32, height: 64}).draw_styled(&draw_style, &mut display).unwrap();
display.flush().await.unwrap();
let mut painter = UiPainter(&mut display);
painter.screen_transition(Screen::Blank, Screen::Bootsplash, &ui_state).await;
for i in 0..255 {
painter.blank();
painter.draw_screen(Screen::Bootsplash, i, &ui_state);
painter.commit().await;
}
for pos in 0..16 {
Rectangle::new(Point::zero(), Size { width: figments::liber8tion::interpolate::ease_in_out_quad(pos*8) as u32, height: 64}).draw_styled(&erase_style, &mut display).unwrap();
Text::with_baseline("Renderbug v4", Point::new(0, 16), logo_style, Baseline::Top)
.draw(&mut display)
.unwrap();
display.flush().await.unwrap();
}
Timer::after_secs(2).await;
painter.screen_transition(Screen::Bootsplash, Screen::Home, &ui_state).await;
info!("OLED display is ready!");
loop {
// FIXME: Need to implement sleep handling to electrically turn the display on/off and do a sleep animation
display.clear(BinaryColor::Off).unwrap();
// FIXME: Need to implement sleep handling to electrically turn the display on/off
if !ui_state.sleep {
// Sensor indicators
let gps_img = if ui_state.gps_online {
&images::GPS_ON
} else {
&images::GPS_OFF
};
let imu_img = if ui_state.imu_online {
&images::IMU_ON
} else {
&images::IMU_OFF
};
Image::new(gps_img, Point::zero()).draw(&mut display).unwrap();
Image::new(imu_img, Point::new((gps_img.size().width + 2) as i32, 0)).draw(&mut display).unwrap();
Image::new(&images::BIKE, Point::new((128 / 2 - images::BIKE.size().width / 2) as i32, 24)).draw(&mut display).unwrap();
let headlight_img = if ui_state.headlight {
&images::HEADLIGHT_ON
} else {
&images::HEADLIGHT_OFF
};
let brakelight_img = if ui_state.brakelight {
&images::BRAKELIGHT_ON
} else {
&images::BRAKELIGHT_OFF
};
Image::new(headlight_img, Point::new(((128 / 2 - images::BIKE.size().width / 2) - 18) as i32, 28)).draw(&mut display).unwrap();
Image::new(brakelight_img, Point::new(((128 / 2 + images::BIKE.size().width / 2) + 2) as i32, 28)).draw(&mut display).unwrap();
Text::with_alignment(&format!("{}", ui_state.velocity), Point::new(128 / 2, 12), speed_style, Alignment::Center)
.draw(&mut display)
.unwrap();
// TODO: Replace the state texts with cute animations or smth
Text::with_alignment(&format!("{:?}", ui_state.motion), Point::new(128 / 2, 64 - 3), text_style, Alignment::Center)
.draw(&mut display)
.unwrap();
Text::with_alignment(&format!("{:?}", ui_state.scene), Point::new(128 / 2, 64 - 13), text_style, Alignment::Center)
.draw(&mut display)
.unwrap();
Line::new(Point::new(0, 18), Point::new(128, 18)).draw_styled(&inactive_style, &mut display).unwrap();
painter.blank();
painter.draw_screen(Screen::Home, 0, &ui_state);
painter.commit().await;
}
display.flush().await.unwrap();
let evt = events.next_message_pure().await;
match evt {
Telemetry::Prediction(Prediction::Velocity(v)) => ui_state.velocity = v,
Telemetry::Prediction(Prediction::Location(loc)) => ui_state.location = loc,
Telemetry::Prediction(Prediction::Motion(motion)) => ui_state.motion = motion,
Telemetry::Notification(crate::events::Notification::SceneChange(scene)) => ui_state.scene = scene,
Telemetry::Notification(Notification::SceneChange(scene)) => ui_state.scene = scene,
Telemetry::Notification(Notification::SetBrakelight(b)) => ui_state.brakelight = b,
Telemetry::Notification(Notification::SetHeadlight(b)) => ui_state.headlight = b,
Telemetry::Notification(Notification::SensorOffline(SensorSource::IMU)) => ui_state.imu_online = false,
Telemetry::Notification(Notification::SensorOnline(SensorSource::IMU)) => ui_state.imu_online = true,
Telemetry::Notification(Notification::SensorOffline(SensorSource::GPS)) => ui_state.gps_online = false,
Telemetry::Notification(Notification::SensorOnline(SensorSource::GPS)) => ui_state.gps_online = true,
Telemetry::Notification(Notification::Sleep) => ui_state.sleep = true,
Telemetry::Notification(Notification::WakeUp) => ui_state.sleep = false,
Telemetry::Notification(Notification::Sleep) => {
painter.screen_transition(Screen::Home, Screen::Sleeping, &ui_state).await;
Timer::after_secs(1).await;
painter.screen_transition(Screen::Sleeping, Screen::Blank, &ui_state).await;
ui_state.sleep = true
},
Telemetry::Notification(Notification::WakeUp) => {
painter.screen_transition(Screen::Blank, Screen::Waking, &ui_state).await;
Timer::after_secs(1).await;
painter.screen_transition(Screen::Waking, Screen::Home, &ui_state).await;
ui_state.sleep = false
},
_ => ()
}
}

View File

@@ -45,9 +45,8 @@ impl<S: Debug + Surface<Uniforms = Uniforms, CoordinateSpace = SegmentSpace, Pix
pub async fn sleep(&mut self) {
info!("Running sleep sequence");
let fade_out = Animation::default().duration(Duration::from_secs(1)).from(255).to(0);
let mut disp_anim = AnimDisplay(&mut self.display);
fade_out.apply(&mut disp_anim).await;
TURN_OFF.apply(&mut disp_anim).await;
warn!("Resetting safety lights");
self.brakelight.set_visible(false);
@@ -87,15 +86,14 @@ impl<S: Debug + Surface<Uniforms = Uniforms, CoordinateSpace = SegmentSpace, Pix
fade_in.apply(&mut self.brakelight)
);
let fade_out = Animation::default().duration(Duration::from_secs(1)).from(255).to(0);
info!("Fade out overlay");
fade_out.apply(&mut self.overlay).await;
TURN_OFF.apply(&mut self.overlay).await;
self.overlay.set_visible(false);
Timer::after_secs(3).await;
warn!("Turning off safety lights");
join!(
fade_out.apply(&mut self.headlight),
fade_out.apply(&mut self.brakelight)
TURN_OFF.apply(&mut self.headlight),
TURN_OFF.apply(&mut self.brakelight)
);
info!("Wakeup complete!");
}
@@ -109,8 +107,20 @@ impl<S: Debug + Surface<Uniforms = Uniforms, CoordinateSpace = SegmentSpace, Pix
match event {
// Toggling head and brake lights
// FIXME: These should be a Off/Low/High enum, so the stopping brake looks different from the dayrunning brake.
Notification::SetBrakelight(is_on) => self.brakelight.set_on(is_on).await,
Notification::SetHeadlight(is_on) => self.headlight.set_on(is_on).await,
Notification::SetBrakelight(is_on) => {
if is_on {
TURN_ON.apply(&mut self.brakelight).await;
} else {
TURN_OFF.apply(&mut self.brakelight).await;
}
},
Notification::SetHeadlight(is_on) => {
if is_on {
TURN_ON.apply(&mut self.headlight).await;
} else {
TURN_OFF.apply(&mut self.headlight).await;
}
},
Notification::Sleep => self.sleep().await,
Notification::WakeUp => self.wake().await,
@@ -119,6 +129,9 @@ impl<S: Debug + Surface<Uniforms = Uniforms, CoordinateSpace = SegmentSpace, Pix
}
}
const TURN_ON: Animation = Animation::new().duration(Duration::from_secs(1)).from(0).to(255);
const TURN_OFF: Animation = Animation::new().duration(Duration::from_secs(1)).from(255).to(0);
#[embassy_executor::task]
pub async fn safety_ui_main(mut events: DynSubscriber<'static, Notification>, mut ui: SafetyUi<<UiSurfacePool as Surfaces<SegmentSpace>>::Surface>) {
// Wait for the renderer to start running