diff --git a/Cargo.toml b/Cargo.toml index 54b5827..bd1bf02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,10 +4,6 @@ name = "co2-meter" rust-version = "1.88" version = "0.1.0" -[[bin]] -name = "co2-meter" -path = "./src/bin/main.rs" - [dependencies] esp-hal = { version = "1.0.0", features = ["defmt", "esp32c3", "unstable"] } diff --git a/assets/co2-icon.bmp b/assets/co2-icon.bmp index a74e213..e69df2d 100644 Binary files a/assets/co2-icon.bmp and b/assets/co2-icon.bmp differ diff --git a/assets/co2-icon.svg b/assets/co2-icon.svg index 391b8ec..ed04641 100644 --- a/assets/co2-icon.svg +++ b/assets/co2-icon.svg @@ -24,7 +24,7 @@ inkscape:deskcolor="#d1d1d1" inkscape:document-units="mm" inkscape:zoom="7.6922777" - inkscape:cx="29.510115" + inkscape:cx="29.575115" inkscape:cy="28.27511" inkscape:window-width="1916" inkscape:window-height="1032" @@ -40,7 +40,7 @@ id="layer1" transform="translate(0.08272917,0.15224179)"> f32 { - ((x - x_min) / (x_max - x_min)) * (y_max - y_min) + y_min -} - -const DEFAULT_LUT: [Rgb565; 5] = [ - Rgb565::new(0, 16, 11), - Rgb565::new(11, 20, 17), - Rgb565::new(23, 20, 18), - Rgb565::new(31, 24, 12), - Rgb565::new(31, 41, 0), -]; - -fn rgb565_interpolate(a: Rgb565, b: Rgb565, x: f32) -> Rgb565 { - Rgb565::new( - (a.r() as f32 * (1. - x)) as u8 + (b.r() as f32 * x) as u8, - (a.g() as f32 * (1. - x)) as u8 + (b.g() as f32 * x) as u8, - (a.b() as f32 * (1. - x)) as u8 + (b.b() as f32 * x) as u8, - ) -} - -fn color_lut(mut x: f32, colors: &[Rgb565]) -> Rgb565 { - if x == 1. { - return colors[colors.len() - 1]; - } - - if x == 0. { - return colors[0]; - } - - x *= (colors.len() - 1) as f32; - - let index = libm::floorf(x); - let interp = x - index; - - rgb565_interpolate(colors[index as usize], colors[index as usize + 1], interp) -} - -pub fn graph_data>(data: &[f32], target: &mut T) { - let min = data.iter().copied().reduce(f32::min).unwrap_or(0.); - let max = data.iter().copied().reduce(f32::max).unwrap_or(0.); - data.iter().map(|x| x); - let size = Size::new( - target.bounding_box().size.width, - target.bounding_box().size.height, - ); - - let mut start = Point::new( - 0, - map_float(data[0], min, max, size.height as f32, 0.) as i32, - ); - let point_count = size.width / 2; - - for y in 0..size.height { - let value = map_float(y as f32, size.height as f32, 0., min, max); - let lut_color = color_lut( - map_float(y as f32, size.height as f32, 0., 0., 1.), - &DEFAULT_LUT, - ); - - let color = rgb565_interpolate(Rgb565::BLACK, lut_color, 0.3); - for x in 0..size.width { - let pos = map_float( - x as f32, - 0., - size.width as f32 - 1., - 0., - (data.len() - 1) as f32, - ); - - // Sample - let index = libm::floorf(pos).min((data.len() - 2) as f32); - let interpolation = pos - index; - let curve_value = data[index as usize] * (1. - interpolation) - + data[index as usize + 1] * interpolation; - // if value <= curve_value && (x + y) % 2 == 0 { - // let _ = Pixel(Point::new(x as i32, y as i32), color).draw(target); - // } - if (x as i32 - y as i32) % 6 == 0 { - if value <= curve_value { - let mut pixel_color = Rgb565::new(1, 2, 1); - pixel_color = color; - let _ = Pixel(Point::new(x as i32 - 2, y as i32), pixel_color).draw(target); - } - } - } - } - - for (i, x) in data.iter().skip(1).enumerate() { - let point = Point::new( - map_float(i as f32, 0., data.len() as f32 - 1., 0., size.width as f32) as i32, - map_float(*x, min, max, size.height as f32, 0.) as i32, - ); - let factor = map_float(*x, min, max, 0., 1.); - let _ = Line::new(start, point) - .into_styled(PrimitiveStyle::with_stroke( - color_lut(factor, &DEFAULT_LUT), - 2, - )) - .draw(target); - start = point; - } - - // for i in 0..point_count { - // // Sample data - // let index = map_float( - // i as f32, - // 0., - // point_count as f32, - // 0 as f32, - // data.len() as f32, - // ) as usize; - // let value = data[index]; - // let point = Point::new( - // map_float(i as f32, 0., point_count as f32, 0., size.width as f32) as i32, - // map_float(value, min, max, 0., size.height as f32) as i32, - // ); - // - // let _ = Line::new(start, point) - // .into_styled(PrimitiveStyle::with_stroke(Rgb565::WHITE, 2)) - // .draw(target); - // start = point; - // } -} diff --git a/src/bin/main.rs b/src/bin/main.rs deleted file mode 100644 index d756fc2..0000000 --- a/src/bin/main.rs +++ /dev/null @@ -1,199 +0,0 @@ -#![no_std] -#![no_main] -#![deny( - clippy::mem_forget, - reason = "mem::forget is generally not safe to do with esp_hal types, especially those \ - holding buffers for the duration of a data transfer." -)] -#![allow(unreachable_code)] - -mod colors; -mod graph; -mod images; -mod sampler; -mod views; - -use buoyant::primitives::Size; -use buoyant::view::AsDrawable; -use buoyant::view::Image; -use buoyant::view::ViewExt; -use core::default::Default; -use core::iter::Iterator; -use defmt::info; -use embedded_graphics::Drawable; -use embedded_graphics::framebuffer::Framebuffer; -use embedded_graphics::framebuffer::buffer_size; -use embedded_graphics::image::ImageRaw; -use embedded_graphics::pixelcolor::raw::LittleEndian; -use embedded_graphics::prelude::Point; -use embedded_graphics::prelude::RgbColor; -use embedded_hal_bus::spi::ExclusiveDevice; -use esp_hal::clock::CpuClock; -use esp_hal::delay::Delay; -use esp_hal::gpio::Level; -use esp_hal::gpio::Output; -use esp_hal::gpio::OutputConfig; -use esp_hal::time::Rate; -use heapless::format; -use mipidsi::interface::SpiInterface; -use mipidsi::models::ST7789; -use mipidsi::options::Orientation; -use mipidsi::options::Rotation; - -use buoyant::view::HStack; -use buoyant::view::Spacer; -use buoyant::view::View; -use core::env; -use embedded_graphics::pixelcolor::Rgb565; -use esp_backtrace as _; -use esp_hal::main; -use esp_println as _; -use tinybmp::Bmp; - -use crate::graph::graph_data; -use crate::images::StaticImage; - -extern crate alloc; - -// This creates a default app-descriptor required by the esp-idf bootloader. -// For more information see: -esp_bootloader_esp_idf::esp_app_desc!(); - -#[main] -fn main() -> ! { - // generator version: 1.0.1 - images::prepare_images(); - - esp_alloc::heap_allocator!(size: 32 * 1024); - let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max()); - let peripherals = esp_hal::init(config); - let mut timer = Delay::new(); - - let spi = esp_hal::spi::master::Spi::new( - peripherals.SPI2, - esp_hal::spi::master::Config::default() - .with_mode(esp_hal::spi::Mode::_0) - .with_frequency(Rate::from_mhz(80)), - ) - .unwrap() - .with_sck(peripherals.GPIO4) - .with_mosi(peripherals.GPIO6); - - let mut cs_output = Output::new(peripherals.GPIO1, Level::High, OutputConfig::default()); - cs_output.set_high(); - let spi_device = ExclusiveDevice::new_no_delay(spi, cs_output).unwrap(); - let _ = Output::new(peripherals.GPIO0, Level::High, OutputConfig::default()); - - let mut buffer = [0_u8; 512]; - - // Define the display interface with no chip select - let di = SpiInterface::new( - spi_device, - Output::new(peripherals.GPIO2, Level::Low, OutputConfig::default()), - &mut buffer, - ); - - let mut display = mipidsi::Builder::new(ST7789, di) - .reset_pin(Output::new( - peripherals.GPIO3, - Level::Low, - OutputConfig::default(), - )) - .invert_colors(mipidsi::options::ColorInversion::Inverted) - .init(&mut timer) - .unwrap(); - - let mut fb = - Framebuffer::(240, 240) }>::new( - ); - //fb.clear(Rgb565::BLACK).unwrap(); - - // views::menu::menu_view() - // .as_drawable(Size::new(240, 240), Rgb565::WHITE) - // .draw(&mut fb) - // .unwrap(); - - let mut x = [0.; 100]; - for (i, x) in x.iter_mut().enumerate() { - *x = libm::sinf(i as f32 / 10.); - } - graph_data(&x, &mut fb); - - let img_raw = ImageRaw::::new(fb.data(), 240); - let image = embedded_graphics::image::Image::new(&img_raw, Point::zero()); - image.draw(&mut display).unwrap(); - // embedded_graphics::image::Image::new(get_image!(images::HUMIDITY_ICON), Point::zero()) - // .draw(&mut display) - // .unwrap(); - - info!("Finished !"); - loop { - core::hint::spin_loop(); - } - - // for inspiration have a look at the examples at https://github.com/esp-rs/esp-hal/tree/esp-hal-v1.0.0/examples/src/bin -} - -pub enum MenuIndicatorType { - Temperature(f32), - Humidity(f32), - Co2(u32), - Voc(u32), -} - -impl MenuIndicatorType { - pub fn get_corresponding_icon(&self) -> &'static StaticImage { - match self { - MenuIndicatorType::Temperature(_) => &images::TEMPERATURE_ICON, - MenuIndicatorType::Humidity(_) => &images::HUMIDITY_ICON, - MenuIndicatorType::Co2(_) => &images::CO2_ICON, - MenuIndicatorType::Voc(_) => &images::VOC_ICON, - } - } - - pub fn get_corresponding_unit_string(&self) -> &'static str { - match self { - MenuIndicatorType::Temperature(_) => "C", - MenuIndicatorType::Humidity(_) => "%", - MenuIndicatorType::Co2(_) => "ppm", - MenuIndicatorType::Voc(_) => "ppb", - } - } - - pub fn get_value_str(&self) -> heapless::String<16> { - match self { - MenuIndicatorType::Temperature(temp) => format!(16; "{:.1}", temp).unwrap(), - MenuIndicatorType::Humidity(hum) => format!(16; "{:.1}", hum).unwrap(), - MenuIndicatorType::Co2(co2) => format!(16; "{}", co2).unwrap(), - MenuIndicatorType::Voc(voc) => format!(16; "{}", voc).unwrap(), - } - } -} - -pub enum Tendency { - Rising, - Steady, - Falling, -} - -impl Tendency { - pub fn get_corresponding_icon(&self) -> &'static StaticImage { - match self { - Self::Rising => &images::TENDENCY_RISING, - Self::Steady => &images::TENDENCY_STEADY, - Self::Falling => &images::TENDENCY_FALLING, - } - } -} - -fn tendency_indicator(tendency: Tendency) -> impl View { - HStack::new(( - Image::new(get_image!(tendency.get_corresponding_icon())) - .flex_frame() - .with_min_size(10, 20) - .with_max_size(10, 20), - Spacer::default(), - )) - .flex_frame() - .with_max_width(15) -} diff --git a/src/bin/sampler.rs b/src/bin/sampler.rs deleted file mode 100644 index f4426bb..0000000 --- a/src/bin/sampler.rs +++ /dev/null @@ -1,53 +0,0 @@ -use core::cell::RefCell; - -use aht20_driver::AHT20; -use alloc::rc::Rc; -use alloc::vec::Vec; -use core::default::Default; -use embedded_hal_bus::i2c::RcDevice; -use ens160::Ens160; -use esp_hal::Blocking; -use esp_hal::DriverMode; -use esp_hal::delay::Delay; -use esp_hal::gpio::interconnect::PeripheralOutput; -use esp_hal::i2c; -use esp_hal::i2c::master::I2c; -use esp_hal::i2c::master::Instance; -use esp_hal::peripherals; -use esp_hal::peripherals::Peripherals; - -pub struct Sampler<'a> { - ens160: Ens160>>, - aht20: aht20_driver::AHT20Initialized>>, -} - -impl<'a> Sampler<'a> { - pub fn new( - i2c: impl Instance + 'a, - sda: impl PeripheralOutput<'a>, - scl: impl PeripheralOutput<'a>, - mut timer: Delay, - ) -> Self { - let i2c = I2c::new(i2c, Default::default()) - .unwrap() - .with_sda(sda) - .with_scl(scl); - - let i2c = Rc::new(RefCell::new(i2c)); - - let mut ens160 = Ens160::new(embedded_hal_bus::i2c::RcDevice::new(i2c.clone()), 0x53); - timer.delay_millis(500); - ens160.reset().unwrap(); - timer.delay_millis(500); - ens160.operational().unwrap(); - - let aht20_uninit = AHT20::new( - embedded_hal_bus::i2c::RcDevice::new(i2c.clone()), - aht20_driver::SENSOR_ADDRESS, - ); - - let aht20 = aht20_uninit.init(&mut timer).unwrap(); - - Sampler { ens160, aht20 } - } -} diff --git a/src/bin/colors.rs b/src/colors.rs similarity index 93% rename from src/bin/colors.rs rename to src/colors.rs index f37e25d..3fbe474 100644 --- a/src/bin/colors.rs +++ b/src/colors.rs @@ -10,4 +10,4 @@ pub const FRAME_STROKE_COLOR: Rgb565 = Rgb565::new(4, 9, 4); pub const MAIN_TEXT_COLOR: Rgb565 = Rgb565::WHITE; pub const SUB_TEXT_COLOR: Rgb565 = Rgb565::CSS_DARK_GRAY; -pub const FRAME_STROKE: u32 = 3; +pub const FRAME_STROKE: u32 = 1; diff --git a/src/graph.rs b/src/graph.rs new file mode 100644 index 0000000..56f18d1 --- /dev/null +++ b/src/graph.rs @@ -0,0 +1,264 @@ +use embedded_graphics::Pixel; +use embedded_graphics::image::GetPixel; +use embedded_graphics::mono_font::MonoTextStyle; +use embedded_graphics::pixelcolor::Rgb565; +use embedded_graphics::prelude::DrawTarget; +use embedded_graphics::prelude::Drawable; +use embedded_graphics::prelude::Point; +use embedded_graphics::prelude::Primitive; +use embedded_graphics::prelude::RgbColor; +use embedded_graphics::prelude::Size; +use embedded_graphics::primitives::Line; +use embedded_graphics::primitives::PrimitiveStyle; +use embedded_graphics::text::Text; +use embedded_graphics::text::renderer::TextRenderer; +use heapless::format; +use profont::PROFONT_10_POINT; + +fn map_float(x: f32, x_min: f32, x_max: f32, y_min: f32, y_max: f32) -> f32 { + ((x - x_min) / (x_max - x_min)) * (y_max - y_min) + y_min +} + +// const DEFAULT_LUT: [Rgb565; 5] = [ +// Rgb565::new(0, 16, 11), +// Rgb565::new(11, 20, 17), +// Rgb565::new(23, 20, 18), +// Rgb565::new(31, 24, 12), +// Rgb565::new(31, 41, 0), +// ]; +const DEFAULT_LUT: [Rgb565; 4] = [Rgb565::GREEN, Rgb565::YELLOW, Rgb565::RED, Rgb565::RED]; + +fn rgb565_interpolate(a: Rgb565, b: Rgb565, x: f32) -> Rgb565 { + Rgb565::new( + (a.r() as f32 * (1. - x)) as u8 + (b.r() as f32 * x) as u8, + (a.g() as f32 * (1. - x)) as u8 + (b.g() as f32 * x) as u8, + (a.b() as f32 * (1. - x)) as u8 + (b.b() as f32 * x) as u8, + ) +} + +fn color_lut(mut x: f32, colors: &[Rgb565]) -> Rgb565 { + if x == 1. { + return colors[colors.len() - 1]; + } + + if x == 0. { + return colors[0]; + } + + x *= (colors.len() - 1) as f32; + + let index = libm::floorf(x); + let interp = x - index; + + rgb565_interpolate(colors[index as usize], colors[index as usize + 1], interp) +} + +pub fn graph_data(data: I, data_count: usize, target: &mut T) +where + I: Iterator + Clone, + T: DrawTarget + GetPixel, +{ + let min = data.clone().reduce(f32::min).unwrap_or(0.); + let max = data.clone().reduce(f32::max).unwrap_or(0.); + let size = Size::new( + target.bounding_box().size.width, + target.bounding_box().size.height, + ); + + // Draw data as WHITE line + let mut start = Point::new( + 0, + map_float( + data.clone().next().unwrap(), + min, + max, + size.height as f32, + 0., + ) as i32, + ); + for (i, x) in data.clone().skip(1).enumerate() { + let point = Point::new( + map_float(i as f32, 0., data_count as f32 - 2., 0., size.width as f32) as i32, + map_float(x, min, max, size.height as f32, 0.) as i32, + ); + let _ = Line::new(start, point) + .into_styled(PrimitiveStyle::with_stroke(Rgb565::WHITE, 2)) + .draw(target); + start = point; + } + + for x in 0..size.width { + // Start coloring from up to bottom + let mut met_curve = false; + + for y in 0..size.height { + let position = Point::new(x as i32, y as i32); + let pixel = target.pixel(position).unwrap(); + let height_factor = map_float(y as f32, 0., size.height as f32, 1., 0.); + let height_color = color_lut(height_factor, &DEFAULT_LUT); + + if pixel == Rgb565::WHITE { + let _ = Pixel(position, height_color).draw(target); + met_curve = true; + } else if met_curve && (x as i32 - y as i32) % 7 == 0 { + let _ = Pixel( + position, + rgb565_interpolate(height_color, Rgb565::BLACK, 1. - height_factor), + ) + .draw(target); + } + } + } +} + +pub fn min_indicator< + I: Iterator + Clone, + T: DrawTarget + GetPixel, +>( + data: I, + data_count: usize, + target: &mut T, +) { + let size = target.bounding_box().size; + let (min_index, min) = data + .clone() + .enumerate() + .reduce(|a, b| if a.1 < b.1 { a } else { b }) + .unwrap_or((0, 0.)); + + let min_x = map_float( + min_index as f32, + 0., + data_count as f32, + 0., + size.width as f32, + ) as i32; + + // let _ = Line::new(Point::new(min_x, 0), Point::new(min_x, size.height as i32)) + // .into_styled(PrimitiveStyle::with_stroke(Rgb565::RED, 1)) + // .draw(target); + for y in 0..size.height { + if (y / 2) % 2 == 0 { + let position = Point::new(min_x, y as i32); + let _ = Pixel( + position, + rgb565_interpolate(Rgb565::RED, Rgb565::BLACK, 0.6), + ) + .draw(target); + + // let position = Point::new(min_x + 1, y as i32); + // let _ = Pixel( + // position, + // rgb565_interpolate(Rgb565::RED, Rgb565::BLACK, 0.6), + // ) + // .draw(target); + } + } + + let minimum_text = "Minimum"; + let font = &PROFONT_10_POINT; + + let text_start = if min_x < (size.width / 2) as i32 { + 5 + } else { + -((minimum_text.len() + 1) as i32 * font.character_size.width as i32 + 3) + }; + + let style = MonoTextStyle::new( + &PROFONT_10_POINT, + rgb565_interpolate(Rgb565::WHITE, Rgb565::BLACK, 0.8), + ); + let _ = Text::new( + minimum_text, + Point::new(min_x + text_start as i32, 10), + style, + ) + .draw(target); + + let value = format!(16; "{:.1}", min).unwrap(); + let _ = Text::new( + value.as_str(), + Point::new( + min_x + text_start as i32, + 10 + font.character_size.height as i32, + ), + style, + ) + .draw(target); +} + +pub fn max_indicator< + T: DrawTarget + GetPixel, + I: Iterator + Clone, +>( + data: I, + data_count: usize, + target: &mut T, +) { + let size = target.bounding_box().size; + let (max_index, max) = data + .clone() + .enumerate() + .reduce(|a, b| if a.1 > b.1 { a } else { b }) + .unwrap_or((0, 0.)); + + let max_x = map_float( + max_index as f32, + 0., + data_count as f32, + 0., + size.width as f32, + ) as i32; + + for y in 0..size.height { + if (y / 2) % 2 == 0 { + let position = Point::new(max_x, y as i32); + let _ = Pixel( + position, + rgb565_interpolate(Rgb565::GREEN, Rgb565::BLACK, 0.6), + ) + .draw(target); + + // let position = Point::new(max_x + 1, y as i32); + // let _ = Pixel( + // position, + // rgb565_interpolate(Rgb565::GREEN, Rgb565::BLACK, 0.6), + // ) + // .draw(target); + } + } + + let maximum_text = "Maximum"; + let font = &PROFONT_10_POINT; + + let text_start = if max_x < (size.width / 2) as i32 { + 5 + } else { + -((maximum_text.len() + 1) as i32 * font.character_size.width as i32 + 3) + }; + + let style = MonoTextStyle::new( + &PROFONT_10_POINT, + rgb565_interpolate(Rgb565::WHITE, Rgb565::BLACK, 0.8), + ); + let _ = Text::new( + maximum_text, + Point::new( + max_x + text_start as i32, + size.height as i32 - font.character_size.height as i32 * 2, + ), + style, + ) + .draw(target); + + let value = format!(16; "{:.1}", max).unwrap(); + let _ = Text::new( + value.as_str(), + Point::new( + max_x + text_start as i32, + size.height as i32 - font.character_size.height as i32, + ), + style, + ) + .draw(target); +} diff --git a/src/bin/images.rs b/src/images.rs similarity index 76% rename from src/bin/images.rs rename to src/images.rs index 4e0785e..3c3da9c 100644 --- a/src/bin/images.rs +++ b/src/images.rs @@ -43,13 +43,13 @@ macro_rules! get_image { pub fn prepare_images() { unsafe { - load_image!(HUMIDITY_ICON, "../../assets/humidity-icon.bmp"); - load_image!(TEMPERATURE_ICON, "../../assets/temperature-icon.bmp"); - load_image!(VOC_ICON, "../../assets/voc-icon.bmp"); - load_image!(CO2_ICON, "../../assets/co2-icon.bmp"); + load_image!(HUMIDITY_ICON, "../assets/humidity-icon.bmp"); + load_image!(TEMPERATURE_ICON, "../assets/temperature-icon.bmp"); + load_image!(VOC_ICON, "../assets/voc-icon.bmp"); + load_image!(CO2_ICON, "../assets/co2-icon.bmp"); - load_image!(TENDENCY_RISING, "../../assets/indic-rising.bmp"); - load_image!(TENDENCY_STEADY, "../../assets/indic-steady.bmp"); - load_image!(TENDENCY_FALLING, "../../assets/indic-falling.bmp"); + load_image!(TENDENCY_RISING, "../assets/indic-rising.bmp"); + load_image!(TENDENCY_STEADY, "../assets/indic-steady.bmp"); + load_image!(TENDENCY_FALLING, "../assets/indic-falling.bmp"); } } diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 0c9ac1a..0000000 --- a/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -#![no_std] diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..269dc2f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,395 @@ +#![no_std] +#![no_main] +#![deny( + clippy::mem_forget, + reason = "mem::forget is generally not safe to do with esp_hal types, especially those \ + holding buffers for the duration of a data transfer." +)] +#![allow(unreachable_code)] + +mod colors; +mod graph; +mod images; +mod sampler; +mod views; + +use buoyant::primitives::Pixel; +use buoyant::primitives::Size; +use buoyant::primitives::geometry::Rectangle; +use buoyant::view::AsDrawable; +use buoyant::view::Image; +use buoyant::view::ViewExt; +use core::default::Default; +use core::iter::Iterator; +use core::ops::Sub; +use defmt::info; +use embedded_graphics::Drawable; +use embedded_graphics::framebuffer::Framebuffer; +use embedded_graphics::framebuffer::buffer_size; +use embedded_graphics::image::GetPixel; +use embedded_graphics::image::ImageRaw; +use embedded_graphics::pixelcolor::raw::LittleEndian; +use embedded_graphics::prelude::Dimensions; +use embedded_graphics::prelude::DrawTarget; +use embedded_graphics::prelude::Point; +use embedded_graphics::prelude::Primitive; +use embedded_graphics::prelude::RgbColor; +use embedded_graphics::primitives::PrimitiveStyle; +use embedded_hal_bus::spi::ExclusiveDevice; +use esp_hal::clock::CpuClock; +use esp_hal::delay::Delay; +use esp_hal::gpio::Input; +use esp_hal::gpio::InputConfig; +use esp_hal::gpio::Level; +use esp_hal::gpio::Output; +use esp_hal::gpio::OutputConfig; +use esp_hal::gpio::Pull; +use esp_hal::interrupt; +use esp_hal::riscv::asm::delay; +use esp_hal::time::Rate; +use heapless::deque; +use heapless::format; +use heapless::history_buf; +use mipidsi::interface::SpiInterface; +use mipidsi::models::ST7789; + +use buoyant::view::HStack; +use buoyant::view::Spacer; +use buoyant::view::View; +use core::env; +use embedded_graphics::pixelcolor::Rgb565; +use esp_backtrace as _; +use esp_hal::main; +use esp_println as _; + +use crate::colors::BACKGROUND_COLOR; +use crate::graph::graph_data; +use crate::graph::max_indicator; +use crate::graph::min_indicator; +use crate::images::StaticImage; +use crate::sampler::History; +use crate::sampler::Sample; +use crate::sampler::Sampler; + +extern crate alloc; + +// This creates a default app-descriptor required by the esp-idf bootloader. +// For more information see: +esp_bootloader_esp_idf::esp_app_desc!(); + +#[main] +fn main() -> ! { + // generator version: 1.0.1 + images::prepare_images(); + + esp_alloc::heap_allocator!(size: 32 * 1024); + let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max()); + let peripherals = esp_hal::init(config); + let mut timer = Delay::new(); + + let spi = esp_hal::spi::master::Spi::new( + peripherals.SPI2, + esp_hal::spi::master::Config::default() + .with_mode(esp_hal::spi::Mode::_0) + .with_frequency(Rate::from_mhz(80)), + ) + .unwrap() + .with_sck(peripherals.GPIO4) + .with_mosi(peripherals.GPIO6); + + let mut cs_output = Output::new(peripherals.GPIO0, Level::High, OutputConfig::default()); + cs_output.set_high(); + let spi_device = ExclusiveDevice::new_no_delay(spi, cs_output).unwrap(); + + let mut buffer = [0_u8; 512]; + + // Define the display interface with no chip select + let di = SpiInterface::new( + spi_device, + Output::new(peripherals.GPIO1, Level::Low, OutputConfig::default()), + &mut buffer, + ); + + let mut display = mipidsi::Builder::new(ST7789, di) + .invert_colors(mipidsi::options::ColorInversion::Inverted) + .init(&mut timer) + .unwrap(); + + let mut fb = + Framebuffer::(240, 240) }>::new( + ); + + // views::menu::menu_view() + // .as_drawable(Size::new(240, 240), Rgb565::WHITE) + // .draw(&mut fb) + // .unwrap(); + + // embedded_graphics::image::Image::new(get_image!(images::HUMIDITY_ICON), Point::zero()) + // .draw(&mut display) + // .unwrap(); + + let mut sampler = Sampler::new( + peripherals.I2C0, + peripherals.GPIO8, + peripherals.GPIO9, + &mut timer, + ); + timer.delay_millis(2000); + let _ = sampler.sample(&mut timer); + + let input_button = Input::new( + peripherals.GPIO10, + InputConfig::default().with_pull(Pull::Down), + ); + let mut last_button_state = Level::Low; + + let mut history = History::new(&mut sampler, &mut timer); + let mut view_state = ViewState::Main; + loop { + // input state + let mut button_pressed = false; + if last_button_state == Level::Low && input_button.is_high() { + button_pressed = true; + } + + last_button_state = input_button.level(); + + if button_pressed { + view_state = view_state.circulate(); + } + + if history.update(&mut sampler, &mut timer) || button_pressed { + // let iter = history.min5.oldest_ordered().map(|x| x.eco2); + // embedded_graphics::primitives::Rectangle::new(Point::zero(), fb.bounding_box().size) + // .into_styled(PrimitiveStyle::with_fill(Rgb565::BLACK)) + // .draw(&mut fb) + // .unwrap(); + // graph_data(iter.clone(), history.min5.len(), &mut fb); + // min_indicator(iter.clone(), history.min5.len(), &mut fb); + // max_indicator(iter.clone(), history.min5.len(), &mut fb); + // + // let img_raw = ImageRaw::::new(fb.data(), 240); + // let image = embedded_graphics::image::Image::new(&img_raw, Point::zero()); + // image.draw(&mut display).unwrap(); + + let app_state = AppState { + sample: *history.min5.last().unwrap(), + history: &history, + tendencies: Tendencies::from_history(&history), + }; + display + .bounding_box() + .into_styled(PrimitiveStyle::with_fill(BACKGROUND_COLOR)) + .draw(&mut fb) + .unwrap(); + view_state.draw_view(&mut fb, app_state); + let img_raw = ImageRaw::::new(fb.data(), 240); + let image = embedded_graphics::image::Image::new(&img_raw, Point::zero()); + image.draw(&mut display).unwrap(); + } + } + + // for inspiration have a look at the examples at https://github.com/esp-rs/esp-hal/tree/esp-hal-v1.0.0/examples/src/bin +} + +pub struct Tendencies { + temperature: Tendency, + humidity: Tendency, + eco2: Tendency, + tvoc: Tendency, +} + +impl Tendencies { + pub fn from_history(history: &History) -> Self { + let mut iter = history.min5.oldest_ordered().rev().copied().take(5); + let len = history.min5.len().min(5); + + if len <= 1 { + return Tendencies { + temperature: Tendency::Steady, + humidity: Tendency::Steady, + eco2: Tendency::Steady, + tvoc: Tendency::Steady, + }; + } + + let mut last = iter.next().unwrap(); + let mut avg_slope = Sample::zero(); + for x in iter { + avg_slope = avg_slope + (last - x); + last = x; + } + + avg_slope = avg_slope * (1. / len as f32); + + const TEMPERATURE_TENDENCY_TRESHOLD: f32 = 0.3; + const HUMIDITY_TENDENCY_TRESHOLD: f32 = 0.3; + const ECO2_TENDENCY_TRESHOLD: f32 = 50.; + const TVOC_TENDENCY_TRESHOLD: f32 = 50.; + Tendencies { + temperature: if avg_slope.temperature > TEMPERATURE_TENDENCY_TRESHOLD { + Tendency::Rising + } else if avg_slope.temperature < -TEMPERATURE_TENDENCY_TRESHOLD { + Tendency::Falling + } else { + Tendency::Steady + }, + humidity: if avg_slope.humidity > HUMIDITY_TENDENCY_TRESHOLD { + Tendency::Rising + } else if avg_slope.humidity < -HUMIDITY_TENDENCY_TRESHOLD { + Tendency::Falling + } else { + Tendency::Steady + }, + eco2: if avg_slope.eco2 > ECO2_TENDENCY_TRESHOLD { + Tendency::Rising + } else if avg_slope.eco2 < -ECO2_TENDENCY_TRESHOLD { + Tendency::Falling + } else { + Tendency::Steady + }, + tvoc: if avg_slope.tvoc > TVOC_TENDENCY_TRESHOLD { + Tendency::Rising + } else if avg_slope.tvoc < -TVOC_TENDENCY_TRESHOLD { + Tendency::Falling + } else { + Tendency::Steady + }, + } + } +} + +pub struct AppState<'a> { + sample: Sample, + tendencies: Tendencies, + history: &'a History, +} + +#[derive(Clone, Copy)] +pub enum ViewState { + Main, + GraphTemperature, + GraphHumidity, + GraphECo2, + GraphTvoc, +} + +impl ViewState { + pub fn circulate(&self) -> Self { + match self { + ViewState::Main => ViewState::GraphTemperature, + ViewState::GraphTemperature => ViewState::GraphHumidity, + ViewState::GraphHumidity => ViewState::GraphECo2, + ViewState::GraphECo2 => ViewState::GraphTvoc, + ViewState::GraphTvoc => ViewState::Main, + } + } + + pub fn draw_view + GetPixel>( + &self, + target: &mut T, + app_state: AppState<'_>, + ) { + match self { + ViewState::Main => { + let _ = views::menu::menu_view(app_state) + .as_drawable(Size::new(240, 240), Rgb565::WHITE) + .draw(target); + } + ViewState::GraphTemperature => { + let _ = views::detail::detailed_view( + MenuIndicatorType::Temperature(10.), + Tendency::Steady, + ) + .as_drawable(Size::new(240, 240), Rgb565::WHITE) + .draw(target); + + let mut graph_fb = Framebuffer::< + Rgb565, + _, + LittleEndian, + 240, + { 240 - 53 }, + { buffer_size::(240, 240) }, + >::new(); + let iter = app_state.history.min5.oldest_ordered().map(|x| x.eco2); + graph_data(iter.clone(), app_state.history.min5.len(), &mut graph_fb); + min_indicator(iter.clone(), app_state.history.min5.len(), &mut graph_fb); + max_indicator(iter.clone(), app_state.history.min5.len(), &mut graph_fb); + + let img_raw = ImageRaw::::new(graph_fb.data(), 240); + let image = embedded_graphics::image::Image::new(&img_raw, Point::new(0, 53)); + let _ = image.draw(target); + } + _ => { + let _ = views::menu::menu_view(app_state) + .as_drawable(Size::new(240, 240), Rgb565::WHITE) + .draw(target); + } + } + } +} + +pub enum MenuIndicatorType { + Temperature(f32), + Humidity(f32), + Co2(u32), + Voc(u32), +} + +impl MenuIndicatorType { + pub fn get_corresponding_icon(&self) -> &'static StaticImage { + match self { + MenuIndicatorType::Temperature(_) => &images::TEMPERATURE_ICON, + MenuIndicatorType::Humidity(_) => &images::HUMIDITY_ICON, + MenuIndicatorType::Co2(_) => &images::CO2_ICON, + MenuIndicatorType::Voc(_) => &images::VOC_ICON, + } + } + + pub fn get_corresponding_unit_string(&self) -> &'static str { + match self { + MenuIndicatorType::Temperature(_) => "C", + MenuIndicatorType::Humidity(_) => "%", + MenuIndicatorType::Co2(_) => "ppm", + MenuIndicatorType::Voc(_) => "ppb", + } + } + + pub fn get_value_str(&self) -> heapless::String<16> { + match self { + MenuIndicatorType::Temperature(temp) => format!(16; "{:.1}", temp).unwrap(), + MenuIndicatorType::Humidity(hum) => format!(16; "{:.1}", hum).unwrap(), + MenuIndicatorType::Co2(co2) => format!(16; "{}", co2).unwrap(), + MenuIndicatorType::Voc(voc) => format!(16; "{}", voc).unwrap(), + } + } +} + +pub enum Tendency { + Rising, + Steady, + Falling, +} + +impl Tendency { + pub fn get_corresponding_icon(&self) -> &'static StaticImage { + match self { + Self::Rising => &images::TENDENCY_RISING, + Self::Steady => &images::TENDENCY_STEADY, + Self::Falling => &images::TENDENCY_FALLING, + } + } +} + +fn tendency_indicator(tendency: Tendency) -> impl View { + HStack::new(( + Image::new(get_image!(tendency.get_corresponding_icon())) + .flex_frame() + .with_min_size(10, 20) + .with_max_size(10, 20), + Spacer::default(), + )) + .flex_frame() + .with_max_width(15) +} diff --git a/src/sampler.rs b/src/sampler.rs new file mode 100644 index 0000000..ffe7619 --- /dev/null +++ b/src/sampler.rs @@ -0,0 +1,209 @@ +use core::cell::RefCell; +use core::ops::Add; +use core::ops::Mul; +use core::ops::Sub; + +use aht20_driver::AHT20; +use alloc::rc::Rc; +use alloc::vec::Vec; +use core::default::Default; +use embedded_hal_bus::i2c::RcDevice; +use ens160::Ens160; +use esp_hal::Blocking; +use esp_hal::DriverMode; +use esp_hal::delay::Delay; +use esp_hal::gpio::interconnect::PeripheralOutput; +use esp_hal::i2c; +use esp_hal::i2c::master::I2c; +use esp_hal::i2c::master::Instance; +use esp_hal::peripherals; +use esp_hal::peripherals::Peripherals; +use esp_hal::time::Duration; +use esp_hal::time::Instant; +use heapless::Deque; +use heapless::HistoryBuf; + +use crate::sampler; + +pub struct Sampler<'a> { + ens160: Ens160>>, + aht20: aht20_driver::AHT20Initialized>>, +} + +#[derive(Clone, Copy, Debug)] +pub struct Sample { + pub temperature: f32, + pub humidity: f32, + pub eco2: f32, + pub tvoc: f32, +} + +impl Sample { + pub fn zero() -> Self { + Sample { + temperature: 0., + humidity: 0., + eco2: 0., + tvoc: 0., + } + } +} + +impl Add for Sample { + type Output = Sample; + + fn add(self, rhs: Sample) -> Self::Output { + Sample { + temperature: self.temperature + rhs.temperature, + humidity: self.humidity + rhs.humidity, + eco2: self.eco2 + rhs.eco2, + tvoc: self.tvoc + rhs.tvoc, + } + } +} + +impl Sub for Sample { + type Output = Sample; + + fn sub(self, rhs: Sample) -> Self::Output { + Sample { + temperature: self.temperature - rhs.temperature, + humidity: self.humidity - rhs.humidity, + eco2: self.eco2 - rhs.eco2, + tvoc: self.tvoc - rhs.tvoc, + } + } +} + +impl Mul for Sample { + type Output = Sample; + + fn mul(self, rhs: Sample) -> Self::Output { + Sample { + temperature: self.temperature * rhs.temperature, + humidity: self.humidity * rhs.humidity, + eco2: self.eco2 * rhs.eco2, + tvoc: self.tvoc * rhs.tvoc, + } + } +} + +impl Mul for Sample { + type Output = Sample; + + fn mul(self, rhs: f32) -> Self::Output { + Sample { + temperature: self.temperature * rhs, + humidity: self.humidity * rhs, + eco2: self.eco2 * rhs, + tvoc: self.tvoc * rhs, + } + } +} + +impl<'a> Sampler<'a> { + pub fn new( + i2c: impl Instance + 'a, + sda: impl PeripheralOutput<'a>, + scl: impl PeripheralOutput<'a>, + timer: &mut Delay, + ) -> Self { + let i2c = I2c::new(i2c, Default::default()) + .unwrap() + .with_sda(sda) + .with_scl(scl); + + let i2c = Rc::new(RefCell::new(i2c)); + + let mut ens160 = Ens160::new(embedded_hal_bus::i2c::RcDevice::new(i2c.clone()), 0x53); + timer.delay_millis(500); + ens160.reset().unwrap(); + timer.delay_millis(500); + ens160.operational().unwrap(); + + let aht20_uninit = AHT20::new( + embedded_hal_bus::i2c::RcDevice::new(i2c.clone()), + aht20_driver::SENSOR_ADDRESS, + ); + + let aht20 = aht20_uninit.init(timer).unwrap(); + + Sampler { ens160, aht20 } + } + + pub fn sample(&mut self, timer: &mut Delay) -> Sample { + let aht20_measurement = self.aht20.measure(timer).unwrap(); + Sample { + temperature: aht20_measurement.temperature, + humidity: aht20_measurement.humidity, + eco2: *self.ens160.eco2().unwrap() as f32, + tvoc: self.ens160.tvoc().unwrap() as f32, + } + } +} + +pub const SECONDS_PER_SAMPLES: usize = 1; +pub const MIN_5_LENGTH: usize = (5 * 60) / SECONDS_PER_SAMPLES; + +pub struct History { + // 5 minutes, every 5 seconds + pub min5: heapless::history_buf::HistoryBuf, + + // 2 hours every 5 seconds + pub hour2: heapless::history_buf::HistoryBuf, + + // 24 hours every 5 minutes + pub day: heapless::history_buf::HistoryBuf, + + samples_since_day: u32, + + last_sample: Instant, +} + +impl History { + pub fn new(sampler: &mut Sampler, timer: &mut Delay) -> Self { + let mut min5 = HistoryBuf::new(); + let mut hour2 = HistoryBuf::new(); + let mut day = HistoryBuf::new(); + + // First sampler + let sample = sampler.sample(timer); + min5.write(sample); + hour2.write(sample); + day.write(sample); + + History { + min5, + hour2, + day, + samples_since_day: 0, + + last_sample: Instant::now(), + } + } + + pub fn update(&mut self, sampler: &mut Sampler, timer: &mut Delay) -> bool { + let now = Instant::now(); + + if now - self.last_sample > Duration::from_secs(SECONDS_PER_SAMPLES as u64) { + let sample = sampler.sample(timer); + self.last_sample = Instant::now(); + self.samples_since_day += 1; + + if self.samples_since_day as usize == MIN_5_LENGTH { + // Compute average + let avg = self.min5.iter().fold(Sample::zero(), |a, b| a + *b) + * (1. / self.min5.len() as f32); + self.day.write(avg); + + self.samples_since_day = 0; + } + + self.min5.write(sample); + self.hour2.write(sample); + true + } else { + false + } + } +} diff --git a/src/bin/views.rs b/src/views.rs similarity index 100% rename from src/bin/views.rs rename to src/views.rs diff --git a/src/bin/views/detail.rs b/src/views/detail.rs similarity index 100% rename from src/bin/views/detail.rs rename to src/views/detail.rs diff --git a/src/bin/views/icon.rs b/src/views/icon.rs similarity index 77% rename from src/bin/views/icon.rs rename to src/views/icon.rs index 8d0f5c1..9fbaf0a 100644 --- a/src/bin/views/icon.rs +++ b/src/views/icon.rs @@ -4,12 +4,15 @@ use buoyant::view::ZStack; use buoyant::view::shape::Rectangle; use embedded_graphics::pixelcolor::Rgb565; +use crate::colors::BACKGROUND_COLOR; use crate::get_image; use crate::images::StaticImage; pub fn icon_box_view(box_color: Rgb565, icon: &'static StaticImage) -> impl View { ZStack::new(( - Rectangle.corner_radius(10).foreground_color(box_color), + Rectangle + .corner_radius(10) + .foreground_color(BACKGROUND_COLOR), buoyant::view::Image::new(get_image!(icon)), )) .flex_frame() diff --git a/src/bin/views/menu.rs b/src/views/menu.rs similarity index 75% rename from src/bin/views/menu.rs rename to src/views/menu.rs index 23d7a7a..080944d 100644 --- a/src/bin/views/menu.rs +++ b/src/views/menu.rs @@ -7,6 +7,7 @@ use embedded_graphics::prelude::*; use profont::PROFONT_18_POINT; use profont::PROFONT_24_POINT; +use crate::AppState; use crate::MenuIndicatorType; use crate::Tendency; use crate::colors::FRAME_BACKGROUD_COLOR; @@ -17,25 +18,37 @@ use crate::colors::SUB_TEXT_COLOR; use crate::get_image; use crate::views::icon::icon_box_view; -pub fn menu_view() -> impl View { +pub fn menu_view(app_state: AppState) -> impl View { VStack::new(( HStack::new(( - main_menu_indicator(MenuIndicatorType::Temperature(31.5), Tendency::Falling), - main_menu_indicator(MenuIndicatorType::Humidity(36.2), Tendency::Steady), + main_menu_indicator( + MenuIndicatorType::Temperature(app_state.sample.temperature), + app_state.tendencies.temperature, + ), + main_menu_indicator( + MenuIndicatorType::Humidity(app_state.sample.humidity), + app_state.tendencies.humidity, + ), )) - .with_spacing(5), + .with_spacing(2), HStack::new(( - main_menu_indicator(MenuIndicatorType::Co2(1329), Tendency::Rising), - main_menu_indicator(MenuIndicatorType::Voc(29), Tendency::Falling), + main_menu_indicator( + MenuIndicatorType::Co2(app_state.sample.eco2 as u32), + app_state.tendencies.eco2, + ), + main_menu_indicator( + MenuIndicatorType::Voc(app_state.sample.tvoc as u32), + app_state.tendencies.tvoc, + ), )) - .with_spacing(5), + .with_spacing(2), )) - .with_spacing(5) + .with_spacing(2) } fn main_menu_indicator(indicator_type: MenuIndicatorType, tendency: Tendency) -> impl View { Rectangle - .corner_radius(10) + .corner_radius(5) .stroked(FRAME_STROKE) .foreground_color(FRAME_STROKE_COLOR) .background(Alignment::Center, || {