//! Screen Output use embedded_graphics::prelude::*; use embedded_graphics::{geometry, mono_font, pixelcolor, primitives, text}; use crate::applogic; pub struct Handler where D: DrawTarget, { target: D, width: u16, height: u16, // internal state prev_state: ScreenState, hb_state: bool, } #[derive(Debug, Default, PartialEq, Eq)] pub enum ScreenState { #[default] Uninitialized, /// aka. "no GNSS fix" or "splash screen" Initial, Error(alloc::string::String), #[cfg(feature = "spat")] GlosaSearching(applogic::GlosaSearching), #[cfg(feature = "spat")] GlosaFound(applogic::GlosaFound), #[cfg(feature = "spat")] GlosaLocked(applogic::GlosaSignalInfo), } #[cfg(feature = "spat")] impl From for ScreenState { fn from(value: applogic::GlosaOutput) -> Self { match value { applogic::GlosaOutput::Searching(glosa_searching) => { Self::GlosaSearching(glosa_searching) } applogic::GlosaOutput::Found(glosa_found) => Self::GlosaFound(glosa_found), applogic::GlosaOutput::Locked(glosa_signal_info) => { Self::GlosaLocked(glosa_signal_info) } applogic::GlosaOutput::Error(err_str) => Self::Error(err_str), } } } impl Handler where D: DrawTarget, { const COLOR_BG: pixelcolor::Rgb565 = pixelcolor::Rgb565::BLACK; const COLOR_FG: pixelcolor::Rgb565 = pixelcolor::Rgb565::WHITE; const BK_STYLE: primitives::PrimitiveStyle = primitives::PrimitiveStyleBuilder::new() .fill_color(Self::COLOR_BG) .build(); const RD_STYLE: primitives::PrimitiveStyle = primitives::PrimitiveStyleBuilder::new() .fill_color(pixelcolor::Rgb565::CSS_RED) .build(); const YE_STYLE: primitives::PrimitiveStyle = primitives::PrimitiveStyleBuilder::new() .fill_color(pixelcolor::Rgb565::new(31, 55, 0)) .build(); const GN_STYLE: primitives::PrimitiveStyle = primitives::PrimitiveStyleBuilder::new() .fill_color(pixelcolor::Rgb565::new(10, 50, 0)) .build(); const BLANK_SIGNAL_STYLE: primitives::PrimitiveStyle = primitives::PrimitiveStyleBuilder::new() .fill_color(pixelcolor::Rgb565::CSS_GRAY) .build(); const DEFAULT_TEXT_STYLE: mono_font::MonoTextStyle<'static, pixelcolor::Rgb565> = mono_font::MonoTextStyle::new(&profont::PROFONT_24_POINT, Self::COLOR_FG); const SMALL_TEXT_STYLE: mono_font::MonoTextStyle<'static, pixelcolor::Rgb565> = mono_font::MonoTextStyle::new(&profont::PROFONT_14_POINT, Self::COLOR_FG); pub fn new(target: D, width: u16, height: u16) -> Self { Self { target, width, height, prev_state: ScreenState::Uninitialized, hb_state: false, } } pub fn update(&mut self, state: ScreenState) -> Result<(), D::Error> { if self.prev_state != state { match &state { ScreenState::Uninitialized | ScreenState::Initial => self.draw_slash_screen()?, ScreenState::GlosaSearching(_glosa_searching) => self.draw_glosa_searching()?, ScreenState::GlosaFound(glosa_found) => self.draw_glosa_found(glosa_found)?, ScreenState::GlosaLocked(glosa_data) => { let prev_glosa_data = if let ScreenState::GlosaLocked(prev_glosa_data) = &self.prev_state { Some(prev_glosa_data.clone()) } else { None }; self.draw_glosa(glosa_data, prev_glosa_data)?; } ScreenState::Error(msg) => self.draw_message(msg)?, } } // Update indicator/ heartbeat { let update_ind_dia = 10; let circle_x_pos = i32::from(self.width) - update_ind_dia - 10; text::Text::with_alignment( "HB", geometry::Point::new(circle_x_pos - 5, 10), Self::SMALL_TEXT_STYLE, text::Alignment::Right, ) .draw(&mut self.target)?; primitives::Circle::new( geometry::Point::new(circle_x_pos, 2), update_ind_dia.cast_unsigned(), ) .into_styled(if self.hb_state { Self::GN_STYLE } else { Self::BK_STYLE }) .draw(&mut self.target)?; } self.prev_state = state; self.hb_state = !self.hb_state; Ok(()) } fn clear(&mut self) -> Result<(), D::Error> where D: DrawTarget, { primitives::Rectangle::new( geometry::Point::new(0, 0), geometry::Size::new(self.width.into(), self.height.into()), ) .into_styled(Self::BK_STYLE) .draw(&mut self.target)?; Ok(()) } #[allow(unused)] pub fn test_img(&mut self) -> Result<(), D::Error> { mipidsi::TestImage::new().draw(&mut self.target)?; Ok(()) } #[allow(unused)] pub fn draw_message(&mut self, msg: &str) -> Result<(), D::Error> { // clear whole display and draw text self.clear()?; text::Text::with_alignment( msg, geometry::Point::new(i32::from(self.width) / 2, 40), Self::DEFAULT_TEXT_STYLE, text::Alignment::Center, ) .draw(&mut self.target)?; Ok(()) } #[allow(unused)] fn draw_glosa_searching(&mut self) -> Result<(), D::Error> { // clear whole display and draw text self.clear()?; text::Text::with_alignment( "No upcoming SPAT", geometry::Point::new(i32::from(self.width) / 2, 130), Self::DEFAULT_TEXT_STYLE, text::Alignment::Center, ) .draw(&mut self.target)?; Ok(()) } #[allow(unused)] fn draw_glosa_found(&mut self, data: &applogic::GlosaFound) -> Result<(), D::Error> { // clear whole display and draw text self.clear()?; let text = if let Some(appr) = data.approach_id { alloc::format!("Int. {}\nApproach {appr}", data.intersection_id) } else { alloc::format!("Int. {}\nno approach ID available", data.intersection_id) }; text::Text::with_alignment( &text, geometry::Point::new(i32::from(self.width) / 2, 130), Self::DEFAULT_TEXT_STYLE, text::Alignment::Center, ) .draw(&mut self.target)?; Ok(()) } #[allow(unused)] fn draw_glosa( &mut self, data: &applogic::GlosaSignalInfo, prev_data: Option, ) -> Result<(), D::Error> { let num_signals = data.signal_groups.len(); if let Some(prev) = prev_data && prev.signal_groups.len() != num_signals { // clear everything if number of signals has changed self.clear(); } else { // only clear maneuver and timing area otherwise primitives::Rectangle::new( geometry::Point::new(0, 105), geometry::Size::new(self.width.into(), 67), ) .into_styled(Self::BK_STYLE) .draw(&mut self.target)?; } // draw intersection ID text::Text::new( &alloc::format!("#{}", data.intersection_id), geometry::Point::new(15, 10), Self::SMALL_TEXT_STYLE, ) .draw(&mut self.target)?; #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] let sig_width = i32::from(self.width) / (num_signals as i32); for (idx, sig) in data.signal_groups.iter().enumerate() { #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] let offset = (idx as i32) * sig_width; let circle_dia = 50; let circle_margin = (sig_width - circle_dia) / 2; let centerline = offset + (sig_width / 2); // draw phase color let style = match sig.phase { applogic::SignalPhase::Unknown => Self::BLANK_SIGNAL_STYLE, applogic::SignalPhase::Red => Self::RD_STYLE, applogic::SignalPhase::Green => Self::GN_STYLE, applogic::SignalPhase::RedYellow | applogic::SignalPhase::Yellow => Self::YE_STYLE, }; primitives::Circle::new( geometry::Point::new(offset + circle_margin, 15), circle_dia.cast_unsigned(), ) .into_styled(style) .draw(&mut self.target)?; // TODO: draw lane type? // draw maneuver text::Text::with_alignment( &maneuver_to_str(sig.maneuver), geometry::Point::new(centerline, 130), Self::DEFAULT_TEXT_STYLE, text::Alignment::Center, ) .draw(&mut self.target)?; // draw timing indicator if let Some(end_sec) = sig.end_sec { text::Text::with_alignment( &alloc::format!("{end_sec}s"), geometry::Point::new(centerline, 160), Self::DEFAULT_TEXT_STYLE, text::Alignment::Center, ) .draw(&mut self.target)?; } } Ok(()) } #[allow(unused)] fn draw_slash_screen(&mut self) -> Result<(), D::Error> { let text_style = mono_font::MonoTextStyle::new(&profont::PROFONT_18_POINT, Self::COLOR_FG); self.clear()?; let sig_width = i32::from(self.width) / 3; let circle_dia = 50; let circle_margin = (sig_width - circle_dia) / 2; for (idx, style) in [Self::RD_STYLE, Self::YE_STYLE, Self::GN_STYLE] .iter() .enumerate() { #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] let offset = (idx as i32) * sig_width; primitives::Circle::new( geometry::Point::new(offset + circle_margin, 15), circle_dia.cast_unsigned(), ) .into_styled(*style) .draw(&mut self.target)?; } // draw welcome text let centerline = i32::from(self.width) / 2; text::Text::with_alignment( "C-ITS Signal Phase Display\nWaiting for GNSS fix...", geometry::Point::new(centerline, 130), text_style, text::Alignment::Center, ) .draw(&mut self.target)?; Ok(()) } } fn maneuver_to_str(value: applogic::v2x::map::Maneuvers) -> alloc::string::String { alloc::format!( "{}{}{}", if value.left_allowed { "<" } else { " " }, if value.staight_allowed { "^" } else { " " }, if value.right_allowed { ">" } else { " " }, ) } /// Creates an [`mipidsi::Display`], width, height tuple for an ST7789 172*320px screen in landscape orientation /// /// # Errors /// Human-readable fatal errors #[allow(clippy::too_many_arguments, clippy::type_complexity)] pub fn make_small_display( scl: IoScl, sda: IoSda, res: IoRes, dc: IoDc, cs: IoCs, bl: IoBl, spi: Spi, spi_buffer: &mut [u8], ) -> Result< ( mipidsi::Display< mipidsi::interface::SpiInterface< '_, embedded_hal_bus::spi::ExclusiveDevice< esp_hal::spi::master::Spi<'_, esp_hal::Blocking>, esp_hal::gpio::Output<'_>, embedded_hal_bus::spi::NoDelay, >, esp_hal::gpio::Output<'_>, >, mipidsi::models::ST7789, esp_hal::gpio::Output<'_>, >, u16, u16, ), alloc::string::String, > where IoScl: esp_hal::gpio::interconnect::PeripheralOutput<'static>, IoSda: esp_hal::gpio::interconnect::PeripheralOutput<'static>, IoRes: esp_hal::gpio::interconnect::PeripheralOutput<'static> + esp_hal::gpio::OutputPin + 'static, IoDc: esp_hal::gpio::interconnect::PeripheralOutput<'static> + esp_hal::gpio::OutputPin + 'static, IoCs: esp_hal::gpio::interconnect::PeripheralOutput<'static> + esp_hal::gpio::OutputPin + 'static, IoBl: esp_hal::gpio::interconnect::PeripheralOutput<'static> + esp_hal::gpio::OutputPin + 'static, Spi: esp_hal::spi::master::Instance + 'static, { const DISPLAY_WIDTH: u16 = 320; const DISPLAY_HEIGHT: u16 = 172; const DISPLAY_HIDDEN_X: u16 = 34; // somehow this display has 34 px of invisible space on the left (in native orientation) let (spi, cs) = setup_st7789_spi(scl, sda, cs, spi) .map_err(|err| alloc::format!("Failed to build SPI config: {err}"))?; let output_config = esp_hal::gpio::OutputConfig::default(); let dc_output = esp_hal::gpio::Output::new(dc, esp_hal::gpio::Level::Low, output_config); let rst_output = esp_hal::gpio::Output::new(res, esp_hal::gpio::Level::High, output_config); let _ = esp_hal::gpio::Output::new(bl, esp_hal::gpio::Level::High, output_config); // enable backlight let mut display_delay = esp_hal::delay::Delay::new(); let spi_device = embedded_hal_bus::spi::ExclusiveDevice::new_no_delay(spi, cs) .map_err(|err| alloc::format!("Failed to initialize SPI device: {err}"))?; let display = mipidsi::Builder::new( mipidsi::models::ST7789, mipidsi::interface::SpiInterface::new(spi_device, dc_output, spi_buffer), ) .display_size(DISPLAY_HEIGHT + DISPLAY_HIDDEN_X, DISPLAY_WIDTH) .reset_pin(rst_output) .invert_colors(mipidsi::options::ColorInversion::Inverted) .orientation(mipidsi::options::Orientation::new().rotate(mipidsi::options::Rotation::Deg90)) .init(&mut display_delay) .map_err(|err| alloc::format!("Failed to initialize display: {err:?}"))?; Ok((display, DISPLAY_WIDTH, DISPLAY_HEIGHT)) } /// Builds the SPI interface for the ST7789 display /// /// Returns (spi master, chip-select) tuple /// /// # Errors /// Returns SPI config error when SPI setup failed fn setup_st7789_spi( scl: IoScl, sda: IoSda, cs: IoCs, spi: Spi, ) -> Result< ( esp_hal::spi::master::Spi<'static, esp_hal::Blocking>, esp_hal::gpio::Output<'static>, ), esp_hal::spi::master::ConfigError, > where IoScl: esp_hal::gpio::interconnect::PeripheralOutput<'static>, IoSda: esp_hal::gpio::interconnect::PeripheralOutput<'static>, IoCs: esp_hal::gpio::interconnect::PeripheralOutput<'static> + esp_hal::gpio::OutputPin + 'static, Spi: esp_hal::spi::master::Instance + 'static, { let spi_config = esp_hal::spi::master::Config::default() .with_frequency(esp_hal::time::Rate::from_mhz(40)) .with_mode(esp_hal::spi::Mode::_0); let spi: esp_hal::spi::master::Spi<'_, esp_hal::Blocking> = esp_hal::spi::master::Spi::new(spi, spi_config)? .with_sck(scl) .with_mosi(sda); let cs_output = esp_hal::gpio::Output::new( cs, esp_hal::gpio::Level::High, esp_hal::gpio::OutputConfig::default(), ); Ok((spi, cs_output)) }