From 1a5012dec51d5daa7fe0cd2727c79b20b4ec63cf Mon Sep 17 00:00:00 2001 From: Jannik Beyerstedt Date: Sat, 1 Aug 2026 21:31:04 +0200 Subject: [PATCH] Adapt to new screen, add more GNSS and timing info --- Readme.md | 2 +- src/applogic/mod.rs | 70 +++++++---- src/main.rs | 20 +++- src/screen.rs | 277 +++++++++++++++++++++++++++++++++++++------- 4 files changed, 300 insertions(+), 69 deletions(-) diff --git a/Readme.md b/Readme.md index c2ecbab..5fb34e6 100644 --- a/Readme.md +++ b/Readme.md @@ -8,7 +8,7 @@ The code is currently tailored to: - U-Blox M10Q GNSS (connected on GPIO12 (RX), GPIO11 (TX), and GPIO25 (reset)) or Seeed XIAO L67K (connected on GPIO12 (RX), GPIO11 (TX), GPIO1 (wakeup) and GPIO25 (reset)) only available with `gnss_ubx` or `gnss_l67k` feature (which is included in `spat` feature) -- Some ST7789 172*320px screen (connected on GPIO8 (SCL/ SCK), GPIO10 (SDA/ MOSI), GPIO7 (reset), GPIO23 (DC), GPIO24 (CS), TODO GPIO0 (BL)) +- Some ST7789 240*280px screen (connected on GPIO8 (SCL/ SCK), GPIO10 (SDA/ MOSI), GPIO7 (reset), GPIO23 (DC), GPIO24 (CS), GPIO0 (BL)) only available with `screen` feature (which is included in `spat` feature) ## Features diff --git a/src/applogic/mod.rs b/src/applogic/mod.rs index 7e7ebc4..55672a7 100644 --- a/src/applogic/mod.rs +++ b/src/applogic/mod.rs @@ -106,7 +106,51 @@ pub struct GlosaSignalInfo { pub struct SignalGroupData { pub phase: SignalPhase, pub maneuver: map::Maneuvers, - pub end_sec: Option, + pub timing: Option, +} + +#[cfg(feature = "spat")] +#[derive(Debug, Default, Clone, PartialEq, Eq)] +#[allow(clippy::struct_field_names)] +pub struct TimingData { + pub min_end_sec: i16, + pub likely_end_sec: Option, + pub max_end_sec: Option, +} + +impl TimingData { + pub fn new(value: &map::SignalGroup, now: chrono::DateTime) -> Option { + let min = value + .min_end_time() + .map(|val| Self::time_to_duration_sec(now, val)); + let likely_end_sec = value + .likely_time() + .map(|val| Self::time_to_duration_sec(now, val)); + let max_end_sec = value + .max_end_time() + .map(|val| Self::time_to_duration_sec(now, val)); + + min.map(|min_end_sec| Self { + min_end_sec, + likely_end_sec, + max_end_sec, + }) + } + + fn time_to_duration_sec( + now: chrono::DateTime, + time_point: chrono::DateTime, + ) -> i16 { + use core::ops::Sub as _; + + use num_traits::Float as _; + + let dur = time_point.sub(now).as_seconds_f32(); + #[allow(clippy::cast_possible_truncation)] + let duration_sec = dur.round() as i16; + + duration_sec + } } #[cfg(feature = "spat")] @@ -357,33 +401,17 @@ impl State { let sig_grps = approach_sig_grps .iter() .filter_map(|sig| { - if let Some(manv) = sig.maneuvers() { - use core::ops::Sub; - - use num_traits::Float; - + if let Some(maneuver) = sig.maneuvers() { let phase = sig .phase() .map(core::convert::Into::into) .unwrap_or_default(); - - let end_sec = sig - .likely_time() - .or_else(|| sig.min_end_time()) - .map(|time| { - let dur = - time.sub(self.time.and_utc()).as_seconds_f32(); - - #[allow(clippy::cast_possible_truncation)] - let end_sec = dur.round() as i16; - - end_sec - }); + let timing = TimingData::new(sig, self.time.and_utc()); Some(SignalGroupData { phase, - maneuver: manv, - end_sec, + maneuver, + timing, }) } else { None diff --git a/src/main.rs b/src/main.rs index 02e4855..13be5d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -154,8 +154,8 @@ async fn main(spawner: Spawner) -> ! { let mut spi_buffer = [0_u8; 512]; #[cfg(feature = "screen")] let mut screen = { - // 172*320px in landscape orientation - let (display, width, height) = screen::make_small_display( + // 240*280px in portrait orientation + let (display, size) = screen::make_large_display( peripherals.GPIO8, peripherals.GPIO10, peripherals.GPIO7, @@ -167,7 +167,7 @@ async fn main(spawner: Spawner) -> ! { ) .expect("Fatal error building screen"); - let mut screen = screen::Handler::new(display, width, height); + let mut screen = screen::Handler::new(display, size); let _ = screen .update(screen::ScreenState::Initial) @@ -381,6 +381,15 @@ async fn main(spawner: Spawner) -> ! { } Err(err) => warn!("{err}"), } + + // update GNSS HB on screen + let _ = screen.update_gnss(pvt); + } + ublox::proto27::PacketRef::AckNak(nak) => { + error!("UBX: {nak:?}"); + } + ublox::proto27::PacketRef::AckAck(ack) => { + info!("UBX: {ack:?}"); } _ => { println!("UBX: {packet_ref:?}"); @@ -460,8 +469,9 @@ async fn main(spawner: Spawner) -> ! { if let applogic::GlosaOutput::Locked(data) = &glosa_data { for sig in &data.signal_groups { let duration_str = sig - .end_sec - .map(|v| alloc::format!("for {v} s")) + .timing + .as_ref() + .map(|v| alloc::format!("for {} s", v.min_end_sec)) .unwrap_or_default(); println!("GLOSA: {} {} {duration_str}", sig.maneuver, sig.phase); } diff --git a/src/screen.rs b/src/screen.rs index b2ddf90..791d086 100644 --- a/src/screen.rs +++ b/src/screen.rs @@ -5,17 +5,24 @@ use embedded_graphics::{geometry, mono_font, pixelcolor, primitives, text}; use crate::applogic; +pub struct DisplaySize { + width: u16, + height: u16, + offset_top: i32, + corner_radius: i32, +} + pub struct Handler where D: DrawTarget, { target: D, - width: u16, - height: u16, + size: DisplaySize, // internal state prev_state: ScreenState, hb_state: bool, + gnss_hb_state: bool, } #[derive(Debug, Default, PartialEq, Eq)] @@ -79,16 +86,20 @@ where const DEFAULT_TEXT_STYLE: mono_font::MonoTextStyle<'static, pixelcolor::Rgb565> = mono_font::MonoTextStyle::new(&profont::PROFONT_24_POINT, Self::COLOR_FG); + const MID_TEXT_STYLE: mono_font::MonoTextStyle<'static, pixelcolor::Rgb565> = + mono_font::MonoTextStyle::new(&profont::PROFONT_18_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 { + const BIG_HEIGHT_THLD: u16 = 172; + + pub fn new(target: D, display_size: DisplaySize) -> Self { Self { target, - width, - height, + size: display_size, prev_state: ScreenState::Uninitialized, hb_state: false, + gnss_hb_state: false, } } @@ -115,18 +126,19 @@ where // Update indicator/ heartbeat { let update_ind_dia = 10; - let circle_x_pos = i32::from(self.width) - update_ind_dia - 10; + let circle_x_pos = + i32::from(self.size.width) - update_ind_dia - self.size.corner_radius; text::Text::with_alignment( "HB", - geometry::Point::new(circle_x_pos - 5, 10), + geometry::Point::new(circle_x_pos - 5, 10 + self.size.offset_top), Self::SMALL_TEXT_STYLE, text::Alignment::Right, ) .draw(&mut self.target)?; primitives::Circle::new( - geometry::Point::new(circle_x_pos, 2), + geometry::Point::new(circle_x_pos, 2 + self.size.offset_top), update_ind_dia.cast_unsigned(), ) .into_styled(if self.hb_state { @@ -143,13 +155,67 @@ where Ok(()) } + // on big screns: uses full screen width and height from 250 to 280 pixels + pub fn update_gnss( + &mut self, + pvt: &ublox::nav_pvt::proto27::NavPvtRef, + ) -> Result<(), D::Error> { + use alloc::string::ToString as _; + + // this only applies to big screens + if !self.is_big_height() { + return Ok(()); + } + + let num_sat = pvt.num_satellites(); + let fix_str = match pvt.fix_type() { + ublox::GnssFixType::NoFix + | ublox::GnssFixType::DeadReckoningOnly + | ublox::GnssFixType::TimeOnlyFix => { + alloc::format!("{num_sat:2} sat") + } + ublox::GnssFixType::Fix2D => "2D fix".to_string(), + ublox::GnssFixType::Fix3D | ublox::GnssFixType::GPSPlusDeadReckoning => { + "3D fix".to_string() + } + _ => " ".to_string(), + }; + + // clear area and write information + // note: text position is x baseline, not top left corner + primitives::Rectangle::new( + geometry::Point::new(0, 250 + self.size.offset_top), + geometry::Size::new(self.size.width.into(), 20), + ) + .into_styled(Self::BK_STYLE) + .draw(&mut self.target)?; + + let colon = if self.gnss_hb_state { ":" } else { " " }; + let message = alloc::format!( + "{fix_str}{colon} {:2.0}km/h, {:3.0}°", + pvt.ground_speed_2d() * 3.6, + pvt.heading_motion() + ); + text::Text::with_alignment( + &message, + geometry::Point::new(self.size.corner_radius / 2, 265 + self.size.offset_top), + Self::SMALL_TEXT_STYLE, + text::Alignment::Left, + ) + .draw(&mut self.target)?; + + self.gnss_hb_state = !self.gnss_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()), + geometry::Size::new(self.size.width.into(), self.size.height.into()), ) .into_styled(Self::BK_STYLE) .draw(&mut self.target)?; @@ -157,6 +223,10 @@ where Ok(()) } + fn is_big_height(&self) -> bool { + self.size.height > Self::BIG_HEIGHT_THLD + } + #[allow(unused)] pub fn test_img(&mut self) -> Result<(), D::Error> { mipidsi::TestImage::new().draw(&mut self.target)?; @@ -171,7 +241,7 @@ where text::Text::with_alignment( msg, - geometry::Point::new(i32::from(self.width) / 2, 40), + geometry::Point::new(i32::from(self.size.width) / 2, 40 + self.size.offset_top), Self::DEFAULT_TEXT_STYLE, text::Alignment::Center, ) @@ -186,8 +256,8 @@ where self.clear()?; text::Text::with_alignment( - "No upcoming SPAT", - geometry::Point::new(i32::from(self.width) / 2, 130), + "Searching...", + geometry::Point::new(i32::from(self.size.width) / 2, 130 + self.size.offset_top), Self::DEFAULT_TEXT_STYLE, text::Alignment::Center, ) @@ -209,7 +279,7 @@ where text::Text::with_alignment( &text, - geometry::Point::new(i32::from(self.width) / 2, 130), + geometry::Point::new(i32::from(self.size.width) / 2, 130 + self.size.offset_top), Self::DEFAULT_TEXT_STYLE, text::Alignment::Center, ) @@ -218,6 +288,9 @@ where Ok(()) } + // uses full screen width and height + // - from 0 to 105+67 = 172 pixels for small screens + // - from 0 to 105+67+20 = 192 pixels for big screens #[allow(unused)] fn draw_glosa( &mut self, @@ -226,6 +299,8 @@ where ) -> Result<(), D::Error> { let num_signals = data.signal_groups.len(); + let is_big_height = self.is_big_height(); + if let Some(prev) = prev_data && prev.signal_groups.len() != num_signals { @@ -233,25 +308,30 @@ where self.clear(); } else { // only clear maneuver and timing area otherwise - + let sig_total_height = 67 + if is_big_height { 20 } else { 0 }; primitives::Rectangle::new( - geometry::Point::new(0, 105), - geometry::Size::new(self.width.into(), 67), + geometry::Point::new(0, 105 + self.size.offset_top), + geometry::Size::new(self.size.width.into(), sig_total_height), ) .into_styled(Self::BK_STYLE) .draw(&mut self.target)?; } + // do nothing more when no signals + if num_signals == 0 { + return Ok(()); + } + // draw intersection ID text::Text::new( &alloc::format!("#{}", data.intersection_id), - geometry::Point::new(15, 10), + geometry::Point::new(self.size.corner_radius + 5, 10 + self.size.offset_top), 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); + let sig_width = i32::from(self.size.width) / (num_signals as i32); for (idx, sig) in data.signal_groups.iter().enumerate() { #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] @@ -269,7 +349,7 @@ where applogic::SignalPhase::RedYellow | applogic::SignalPhase::Yellow => Self::YE_STYLE, }; primitives::Circle::new( - geometry::Point::new(offset + circle_margin, 15), + geometry::Point::new(offset + circle_margin, 15 + self.size.offset_top), circle_dia.cast_unsigned(), ) .into_styled(style) @@ -280,24 +360,50 @@ where // draw maneuver text::Text::with_alignment( &maneuver_to_str(sig.maneuver), - geometry::Point::new(centerline, 130), + geometry::Point::new(centerline, 130 + self.size.offset_top), 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)?; + // draw timing indicator(s) + if let Some(timing) = &sig.timing { + if let (Some(likely), Some(max)) = (timing.likely_end_sec, timing.max_end_sec) { + text::Text::with_alignment( + &alloc::format!("{likely}s"), + geometry::Point::new(centerline, 160 + self.size.offset_top), + Self::DEFAULT_TEXT_STYLE, + text::Alignment::Center, + ) + .draw(&mut self.target)?; + + // only for big screen + if is_big_height { + // TODO: show as diff to likely time? + text::Text::with_alignment( + &alloc::format!("{}-{max}", timing.min_end_sec), + geometry::Point::new(centerline, 185 + self.size.offset_top), + Self::MID_TEXT_STYLE, + text::Alignment::Center, + ) + .draw(&mut self.target)?; + } + } else { + text::Text::with_alignment( + &alloc::format!("{}s", timing.min_end_sec), + geometry::Point::new(centerline, 160 + self.size.offset_top), + Self::DEFAULT_TEXT_STYLE, + text::Alignment::Center, + ) + .draw(&mut self.target)?; + } } } + // if is_big_height { + // // TODO: Add ETA + // } + Ok(()) } @@ -307,7 +413,7 @@ where self.clear()?; - let sig_width = i32::from(self.width) / 3; + let sig_width = i32::from(self.size.width) / 3; let circle_dia = 50; let circle_margin = (sig_width - circle_dia) / 2; @@ -319,7 +425,7 @@ where let offset = (idx as i32) * sig_width; primitives::Circle::new( - geometry::Point::new(offset + circle_margin, 15), + geometry::Point::new(offset + circle_margin, 15 + self.size.offset_top), circle_dia.cast_unsigned(), ) .into_styled(*style) @@ -327,10 +433,10 @@ where } // draw welcome text - let centerline = i32::from(self.width) / 2; + let centerline = i32::from(self.size.width) / 2; text::Text::with_alignment( - "C-ITS Signal Phase Display\nWaiting for GNSS fix...", - geometry::Point::new(centerline, 130), + "C-ITS Signal Phase\nWaiting for GNSS...", + geometry::Point::new(centerline, 130 + self.size.offset_top), text_style, text::Alignment::Center, ) @@ -353,7 +459,7 @@ fn maneuver_to_str(value: applogic::v2x::map::Maneuvers) -> alloc::string::Strin /// /// # Errors /// Human-readable fatal errors -#[allow(clippy::too_many_arguments, clippy::type_complexity)] +#[allow(unused, clippy::too_many_arguments, clippy::type_complexity)] pub fn make_small_display( scl: IoScl, sda: IoSda, @@ -378,8 +484,7 @@ pub fn make_small_display( mipidsi::models::ST7789, esp_hal::gpio::Output<'_>, >, - u16, - u16, + DisplaySize, ), alloc::string::String, > @@ -396,10 +501,98 @@ where 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 size = DisplaySize { + width: 320, + height: 172, + offset_top: 0, + corner_radius: 10, + }; + + 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}"))?; + + // Note: height and width are swapped b/c of 90° display rotation + let display = mipidsi::Builder::new( + mipidsi::models::ST7789, + mipidsi::interface::SpiInterface::new(spi_device, dc_output, spi_buffer), + ) + .display_size(size.height + DISPLAY_HIDDEN_X, size.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, size)) +} + +/// Creates an [`mipidsi::Display`], width, height tuple for an ST7789 240*280px screen in portrait orientation +/// +/// # Errors +/// Human-readable fatal errors +#[allow(unused, clippy::too_many_arguments, clippy::type_complexity)] +pub fn make_large_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<'_>, + >, + DisplaySize, + ), + 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_HIDDEN_Y: u16 = 20; // somehow this display has 20 px of invisible space on the top (in native orientation) + + let size = DisplaySize { + width: 240, + height: 280 + DISPLAY_HIDDEN_Y, + offset_top: DISPLAY_HIDDEN_Y.into(), + corner_radius: 30, + }; + let (spi, cs) = setup_st7789_spi(scl, sda, cs, spi) .map_err(|err| alloc::format!("Failed to build SPI config: {err}"))?; @@ -417,14 +610,14 @@ where mipidsi::models::ST7789, mipidsi::interface::SpiInterface::new(spi_device, dc_output, spi_buffer), ) - .display_size(DISPLAY_HEIGHT + DISPLAY_HIDDEN_X, DISPLAY_WIDTH) + .display_size(size.width, size.height + DISPLAY_HIDDEN_Y) .reset_pin(rst_output) .invert_colors(mipidsi::options::ColorInversion::Inverted) - .orientation(mipidsi::options::Orientation::new().rotate(mipidsi::options::Rotation::Deg90)) + .orientation(mipidsi::options::Orientation::new().rotate(mipidsi::options::Rotation::Deg0)) .init(&mut display_delay) .map_err(|err| alloc::format!("Failed to initialize display: {err:?}"))?; - Ok((display, DISPLAY_WIDTH, DISPLAY_HEIGHT)) + Ok((display, size)) } /// Builds the SPI interface for the ST7789 display