0
0
Fork 0
esp32-c_its-companion/src/screen.rs
2026-07-20 11:36:05 +02:00

470 lines
16 KiB
Rust

//! Screen Output
use embedded_graphics::prelude::*;
use embedded_graphics::{geometry, mono_font, pixelcolor, primitives, text};
use crate::applogic;
pub struct Handler<D>
where
D: DrawTarget<Color = pixelcolor::Rgb565>,
{
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<applogic::GlosaOutput> 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<D> Handler<D>
where
D: DrawTarget<Color = pixelcolor::Rgb565>,
{
const COLOR_BG: pixelcolor::Rgb565 = pixelcolor::Rgb565::BLACK;
const COLOR_FG: pixelcolor::Rgb565 = pixelcolor::Rgb565::WHITE;
const BK_STYLE: primitives::PrimitiveStyle<pixelcolor::Rgb565> =
primitives::PrimitiveStyleBuilder::new()
.fill_color(Self::COLOR_BG)
.build();
const RD_STYLE: primitives::PrimitiveStyle<pixelcolor::Rgb565> =
primitives::PrimitiveStyleBuilder::new()
.fill_color(pixelcolor::Rgb565::CSS_RED)
.build();
const YE_STYLE: primitives::PrimitiveStyle<pixelcolor::Rgb565> =
primitives::PrimitiveStyleBuilder::new()
.fill_color(pixelcolor::Rgb565::new(31, 55, 0))
.build();
const GN_STYLE: primitives::PrimitiveStyle<pixelcolor::Rgb565> =
primitives::PrimitiveStyleBuilder::new()
.fill_color(pixelcolor::Rgb565::new(10, 50, 0))
.build();
const BLANK_SIGNAL_STYLE: primitives::PrimitiveStyle<pixelcolor::Rgb565> =
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<Color = pixelcolor::Rgb565>,
{
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<applogic::GlosaSignalInfo>,
) -> 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<IoScl, IoSda, IoRes, IoDc, IoCs, IoBl, Spi>(
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<IoScl, IoSda, IoCs, 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))
}