diff --git a/Cargo.toml b/Cargo.toml index 1b217a1..b5406c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,10 +8,10 @@ authors = ["Felix Suchert "] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -embedded-graphics = "0.7.1" -embedded-plots = "0.2.0" -tinybmp = "0.3.3" -profont = "0.5.0" +embedded-graphics = "0.8" +embedded-plots = "0.2" +tinybmp = "0.5" +profont = "0.6" ssd1675 = { git = "https://github.com/Feliix42/ssd1675" } #ssd1675 = { path = "../ssd1675" } @@ -21,9 +21,11 @@ serde_json = "1.0" # for retrieving weather data from the API ureq = { version = "2.6", features = ["json"] } +# for parsing timestamps from APIs +time = { version = "0.3", features = ["macros", "parsing", "local-offset"] } linux-embedded-hal = { version = "0.3.2", optional = true } -embedded-graphics-simulator = { version = "0.3.0", optional = true } +embedded-graphics-simulator = { version = "0.5", optional = true } [features] diff --git a/src/bin/simulator.rs b/src/bin/simulator.rs index c78ddfc..4334038 100644 --- a/src/bin/simulator.rs +++ b/src/bin/simulator.rs @@ -1,6 +1,7 @@ use embedded_graphics::prelude::*; use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window}; use ssd1675::Color; +use inky_ticker::ApplicationState; fn main() -> Result<(), core::convert::Infallible> { println!("Initialize Display"); @@ -9,18 +10,25 @@ fn main() -> Result<(), core::convert::Infallible> { println!("Creating Window"); let mut window = Window::new("Hello World", &OutputSettings::default()); - //loop { - //println!("Populate display..."); - //inky_ticker::populate(&mut display); + let mut state = ApplicationState::default(); - //println!("Updating window"); - //window.update(&display); + // switch between states every few minutes + let minute = std::time::Duration::from_secs(60); - //std::thread::sleep(std::time::Duration::from_secs(1)); - //} + loop { + display.clear(Color::White).expect("failed to clear screen"); - inky_ticker::populate(&mut display); - window.show_static(&display); + println!("Populating screen"); - Ok(()) + inky_ticker::populate(&mut display, &mut state); + + println!("Updating window"); + window.update(&display); + + + println!("Going to sleep"); + + state.advance_screen(); + std::thread::sleep(minute); + } } diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..6c790b7 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,38 @@ +use embedded_graphics::{ + draw_target::DrawTarget, + mono_font::MonoTextStyle, + prelude::*, + text::{Alignment, Baseline, Text}, +}; +use profont::{PROFONT_12_POINT, PROFONT_24_POINT}; +use ssd1675::Color; +use std::error::Error; +use std::fmt::Debug; + +pub fn display_no_data(display: &mut D, err: E) +where + D: DrawTarget, + D::Error: Debug, + E: Error, +{ + Text::with_alignment( + "KEINE DATEN", + Point::new(106, 28), + MonoTextStyle::new(&PROFONT_24_POINT, Color::Red), + Alignment::Center, + ) + .draw(display) + .expect("error drawing text"); + + eprintln!("[error] Failed to get/parse JSON:\n{}", err); + + // TODO(feliix42): As soon as there is a custom text renderer for full texts, use it here! + Text::with_baseline( + &err.to_string(), + Point::new(5, 32), + MonoTextStyle::new(&PROFONT_12_POINT, Color::Black), + Baseline::Top, + ) + .draw(display) + .expect("error drawing text"); +} diff --git a/src/lib.rs b/src/lib.rs index 133e03d..f57b587 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,121 +1,50 @@ -use embedded_graphics::{ - draw_target::DrawTarget, image::Image, mono_font::MonoTextStyle, prelude::*, text::Text, -}; -use profont::{PROFONT_12_POINT, PROFONT_14_POINT, PROFONT_24_POINT, PROFONT_9_POINT}; +use embedded_graphics::draw_target::DrawTarget; use ssd1675::Color; use std::fmt::Debug; -use std::process::Command; -use std::{fs, io}; -use tinybmp::Bmp; +mod error; +mod pollen; mod weather; -pub fn populate(display: &mut D) +/// Represents the different screens that can be shown on the ticker display. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Screen { + Weather, + PollenRadar, +} + +pub struct ApplicationState { + /// Current copy of the pollen data, if any is available. + pub pollen_data: Option, + /// Currently shown screen + pub current_screen: Screen, +} + +impl Default for ApplicationState { + fn default() -> Self { + Self { + pollen_data: None, + current_screen: Screen::Weather, + } + } +} + +impl ApplicationState { + pub fn advance_screen(&mut self) { + self.current_screen = match self.current_screen { + Screen::Weather => Screen::PollenRadar, + Screen::PollenRadar => Screen::Weather, + } + } +} + +pub fn populate(display: &mut D, state: &mut ApplicationState) where D: DrawTarget, D::Error: Debug, { - weather::get_weather(display) -} - -// TODO(feliix42): Remove dead code here - -pub fn populate_default(display: &mut D) -where - D: DrawTarget, - D::Error: Debug, -{ - Text::new( - "Raspberry Pi", - Point::new(1, 20), - MonoTextStyle::new(&PROFONT_24_POINT, Color::Red), - ) - .draw(display) - .expect("error drawing text"); - - if let Ok(cpu_temp) = read_cpu_temp() { - Text::new( - "CPU Temp:", - Point::new(1, 40), - MonoTextStyle::new(&PROFONT_14_POINT, Color::Black), - ) - .draw(display) - .expect("error drawing text"); - Text::new( - &format!("{:.1}°C", cpu_temp), - Point::new(95, 40), - MonoTextStyle::new(&PROFONT_12_POINT, Color::Red), - ) - .draw(display) - .expect("error drawing text"); + match state.current_screen { + Screen::Weather => weather::get_weather(display), + Screen::PollenRadar => pollen::get_pollen(display, state), } - - if let Some(uptime) = read_uptime() { - Text::new( - uptime.trim(), - Point::new(1, 93), - MonoTextStyle::new(&PROFONT_9_POINT, Color::Black), - ) - .draw(display) - .expect("error drawing text"); - } - - if let Some(uname) = read_uname() { - Text::new( - uname.trim(), - Point::new(1, 84), - MonoTextStyle::new(&PROFONT_9_POINT, Color::Black), - ) - .draw(display) - .expect("error drawing text"); - } - - let bmp_data = include_bytes!("../assets/rain.bmp"); - - // Load 16 BPP 8x8px image. - // Note: The color type is specified explicitly to match the format used by the BMP image. - let bmp = Bmp::::from_slice(bmp_data).unwrap(); - //let bmp: Bmp = bmp.into(); - - // Draw the image with the top left corner at (10, 20) by wrapping it in - // an embedded-graphics `Image`. - Image::new(&bmp, Point::new(10, 20)) - .draw(display) - .expect("Failed to draw image"); -} - -pub fn read_cpu_temp() -> Result { - fs::read_to_string("/sys/class/thermal/thermal_zone0/temp")? - .trim() - .parse::() - .map(|temp| temp as f64 / 1000.) - .map_err(|err| io::Error::new(io::ErrorKind::Other, err)) -} - -pub fn read_uptime() -> Option { - Command::new("uptime") - .arg("-p") - .output() - .ok() - .and_then(|output| { - if output.status.success() { - String::from_utf8(output.stdout).ok() - } else { - None - } - }) -} - -pub fn read_uname() -> Option { - Command::new("uname") - .arg("-smr") - .output() - .ok() - .and_then(|output| { - if output.status.success() { - String::from_utf8(output.stdout).ok() - } else { - None - } - }) } diff --git a/src/pollen/api.rs b/src/pollen/api.rs new file mode 100644 index 0000000..042f2a3 --- /dev/null +++ b/src/pollen/api.rs @@ -0,0 +1,7 @@ +use super::data::RawPollenData; + +pub fn get_pollen_data() -> Result { + let api_url = "https://opendata.dwd.de/climate_environment/health/alerts/s31fg.json"; + + Ok(ureq::get(api_url).call()?.into_json()?) +} diff --git a/src/pollen/data.rs b/src/pollen/data.rs new file mode 100644 index 0000000..e7d1f5f --- /dev/null +++ b/src/pollen/data.rs @@ -0,0 +1,204 @@ +//! Rust data types representing the return values of the [DWD Pollen +//! API](https://opendata.dwd.de/climate_environment/health/alerts/Beschreibung_pollen_s31fg.pdf). +//! +//! Individual region keys and and other data is explained in the OpenData portal documentation +//! linked above. + +use serde::Deserialize; +use std::collections::HashMap; +use std::str::FromStr; +use time::{macros::format_description, OffsetDateTime, PrimitiveDateTime, UtcOffset}; + +// TODO(feliix42): Transparent error handling: remove `unwrap`! + +/// Raw Response Data from the DWD Pollen API with both current Pollen data and forecast information for the +/// next two days. +/// +/// Not intended to be used as is. It must be converted to `PollenData` first using +/// [PollenData::from]. +#[derive(Deserialize)] +pub struct RawPollenData { + // meta fields + /// Name of the Data retrieved (i.e., the title of the forecast) + //pub name: String, + /// Creator of the data + //pub sender: String, + // TODO(feliix42): Write a custom parser stripping the `Uhr` string at the end. + /// Date and time when this report was created + last_update: String, + /// Next update to the data + next_update: String, + ///// Explanations for the individual hazard levels + //pub legend: HashMap, + + // actual data + /// A list of all data for Germany + content: Vec, +} + +#[derive(Deserialize)] +struct RawRegionPollenData { + pub region_name: String, + #[serde(rename = "partregion_name")] + pub part_of_region: String, + pub region_id: u32, + #[serde(rename = "partregion_id")] + pub part_of_region_id: i32, + #[serde(rename = "Pollen")] + pub pollen: HashMap, +} + +#[derive(Deserialize)] +struct RawAllergenDetails { + pub today: String, + pub tomorrow: String, + pub dayafter_to: String, +} + +#[derive(Debug, Eq, PartialEq)] +pub enum ParseError { + // UnknownAllergen, + UnknownIntensity, +} + +/// Response from the DWD Pollen API with both current Pollen data and forecast information for the +/// next two days. +pub struct PollenData { + /// Date and time when this report was created + pub last_update: OffsetDateTime, + /// Next update to the data + pub next_update: OffsetDateTime, + + /// A list of all data for Germany + pub content: Vec, +} + +impl PollenData { + pub fn get_region_by_ids<'a>(&'a self, id: u32, part: i32) -> Option<&'a RegionPollenData> { + self.content.iter().find(|&x| x.region_id == id && x.part_of_region_id == part) + } +} + +impl From for PollenData { + fn from(value: RawPollenData) -> Self { + let format = format_description!("[year]-[month]-[day] [hour]:[minute] Uhr"); + + // we assume the DWD server has the same time zone offset as our Pi. + let local_offset = UtcOffset::current_local_offset().unwrap(); + + let last = PrimitiveDateTime::parse(&value.last_update, format) + .unwrap() + .assume_offset(local_offset); + let next = PrimitiveDateTime::parse(&value.next_update, format) + .unwrap() + .assume_offset(local_offset); + + Self { + last_update: last, + next_update: next, + + content: value + .content + .into_iter() + .map(RegionPollenData::from) + .collect(), + } + } +} + +pub struct RegionPollenData { + pub region_name: String, + pub part_of_region: String, + pub region_id: u32, + pub part_of_region_id: i32, + pub pollen: HashMap, +} + +impl From for RegionPollenData { + fn from(value: RawRegionPollenData) -> Self { + Self { + region_name: value.region_name, + part_of_region: value.part_of_region, + region_id: value.region_id, + part_of_region_id: value.part_of_region_id, + pollen: value + .pollen + .into_iter() + .map(|(k, v)| (k, AllergenDetails::from(v))) + .collect(), + } + } +} + +/// Details about a specific allergen +pub struct AllergenDetails { + pub today: Intensity, + pub tomorrow: Intensity, + pub dayafter_to: Intensity, +} + +impl From for AllergenDetails { + fn from(value: RawAllergenDetails) -> Self { + Self { + today: Intensity::from_str(&value.today).unwrap(), + tomorrow: Intensity::from_str(&value.tomorrow).unwrap(), + dayafter_to: Intensity::from_str(&value.dayafter_to).unwrap(), + } + } +} + +/// Individual allergenes +#[derive(Deserialize, Eq, Hash, PartialEq)] +pub enum Allergene { + Ambrosia, + Roggen, + Birke, + Erle, + Beifuss, + Esche, + Graeser, + Hasel, +} + +/// Intensity of an allergen +#[derive(Copy, Clone, PartialOrd, Ord, PartialEq, Eq)] +pub enum Intensity { + Keine, + KeinBisGering, + Gering, + GeringBisMittel, + Mittel, + MittelBisHoch, + Hoch, +} + +impl Intensity { + pub fn as_fraction(&self) -> f32 { + match *self { + Intensity::Keine => 0_f32, + Intensity::KeinBisGering => 0.1666, + Intensity::Gering => 0.3333, + Intensity::GeringBisMittel => 0.5, + Intensity::Mittel => 0.6666, + Intensity::MittelBisHoch => 0.8333, + Intensity::Hoch => 1_f32, + } + } +} + +impl FromStr for Intensity { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + match s { + "0" => Ok(Intensity::Keine), + "0-1" => Ok(Intensity::KeinBisGering), + "1" => Ok(Intensity::Gering), + "1-2" => Ok(Intensity::GeringBisMittel), + "2" => Ok(Intensity::Mittel), + "2-3" => Ok(Intensity::MittelBisHoch), + "3" => Ok(Intensity::Hoch), + _ => Err(ParseError::UnknownIntensity), + } + } +} diff --git a/src/pollen/mod.rs b/src/pollen/mod.rs new file mode 100644 index 0000000..34b6405 --- /dev/null +++ b/src/pollen/mod.rs @@ -0,0 +1,145 @@ +use embedded_graphics::{ + draw_target::DrawTarget, + mono_font::MonoTextStyle, + prelude::*, + primitives::{Rectangle, RoundedRectangle, PrimitiveStyleBuilder}, + text::{Alignment, Text}, +}; +use profont::{ + PROFONT_12_POINT, PROFONT_18_POINT, +}; +use ssd1675::Color; +use std::fmt::Debug; +use crate::error::display_no_data; +use crate::ApplicationState; +use time::OffsetDateTime; +use data::{Allergene, Intensity}; + +mod api; +pub mod data; + +pub fn get_pollen(display: &mut D, state: &mut ApplicationState) +where + D: DrawTarget, + D::Error: Debug, +{ + // determine whether an update to the state is necessary + let update_needed = if let Some(data) = &state.pollen_data { + let now = OffsetDateTime::now_local().unwrap(); + (data.next_update - now).is_negative() + } else { + true + }; + + let pollen_data = if update_needed { + match api::get_pollen_data() { + Ok(d) => data::PollenData::from(d), + Err(e) => { + state.pollen_data = None; + display_no_data(display, e); + return; + } + } + } else { + std::mem::take(&mut state.pollen_data).unwrap() + }; + + // 80: Sachsen + // 81: Tiefland + let local_data = pollen_data.get_region_by_ids(80, 81).unwrap(); + + // draw results + Text::with_alignment( + "Pollenbelastung", + Point::new(106, 21), + MonoTextStyle::new(&PROFONT_18_POINT, Color::Black), + Alignment::Center, + ) + .draw(display) + .expect("error drawing text"); + + Text::new( + "Roggen", + Point::new(30, 49), + MonoTextStyle::new(&PROFONT_12_POINT, Color::Black), + ) + .draw(display) + .expect("error drawing text"); + + let rye_intensity = local_data.pollen[&Allergene::Roggen].today; + let rye_value = (78_f32 * rye_intensity.as_fraction()).round() as u32; + let highlight_color = if rye_intensity >= Intensity::GeringBisMittel { + Color::Red + } else { + Color::Black + }; + + let outer_style = PrimitiveStyleBuilder::new() + .stroke_width(1) + .stroke_color(highlight_color) + .fill_color(Color::White) + .build(); + let inner_style = PrimitiveStyleBuilder::new() + .stroke_width(0) + .stroke_color(highlight_color) + .fill_color(highlight_color) + .build(); + + RoundedRectangle::with_equal_corners( + Rectangle::new(Point::new(105, 39), Size::new(80, 12)), + Size::new(6, 6) + ) + .into_styled(outer_style) + .draw(display) + .expect("error drawing rectangle"); + + RoundedRectangle::with_equal_corners( + Rectangle::new(Point::new(106, 40), Size::new(rye_value, 10)), + Size::new(5, 5) + ) + .into_styled(inner_style) + .draw(display) + .expect("error drawing rectangle"); + + Text::new( + "Gräser", + Point::new(30, 69), + MonoTextStyle::new(&PROFONT_12_POINT, Color::Black), + ) + .draw(display) + .expect("error drawing text"); + + let outer_style = PrimitiveStyleBuilder::new() + .stroke_width(1) + .stroke_color(Color::Black) + .fill_color(Color::White) + .build(); + let inner_style = PrimitiveStyleBuilder::new() + .stroke_width(0) + .stroke_color(Color::Black) + .fill_color(Color::Black) + .build(); + + RoundedRectangle::with_equal_corners( + Rectangle::new(Point::new(105, 59), Size::new(80, 12)), + Size::new(6, 6) + ) + .into_styled(outer_style) + .draw(display) + .expect("error drawing rectangle"); + + let graeser_intensity = local_data.pollen[&Allergene::Graeser].today; + let graeser_value = (78_f32 * graeser_intensity.as_fraction()).round() as u32; + + RoundedRectangle::with_equal_corners( + Rectangle::new(Point::new(106, 60), Size::new(graeser_value, 10)), + Size::new(5, 5) + ) + .into_styled(inner_style) + .draw(display) + .expect("error drawing rectangle"); + + + // write back current status + state.pollen_data = Some(pollen_data); +} diff --git a/src/weather/data.rs b/src/weather/data.rs index 57506b5..1442a77 100644 --- a/src/weather/data.rs +++ b/src/weather/data.rs @@ -59,7 +59,7 @@ impl WeatherData { } pub fn current_condition(&self) -> Bmp<'static, Color> { - unimplemented!() + self.current.data.weather[0].condition.clone().into() } pub fn forecast_plot_data(&self) -> Vec { @@ -175,7 +175,7 @@ pub struct WeatherDetails { /// specification](https://openweathermap.org/weather-conditions#Weather-Condition-Codes-2). /// It ignores the fine details within weather conditions (e.g. heavy thunderstorm and light /// thunderstorm both map to `Thunderstorm`). -#[derive(Deserialize)] //Debug)] +#[derive(Clone, Deserialize)] //Debug)] #[serde(from = "u16")] pub enum WeatherCondition { Thunderstorm, diff --git a/src/weather/mod.rs b/src/weather/mod.rs index e1baf32..411625c 100644 --- a/src/weather/mod.rs +++ b/src/weather/mod.rs @@ -3,15 +3,15 @@ use embedded_graphics::{ image::Image, mono_font::MonoTextStyle, prelude::*, - text::{Alignment, Baseline, Text}, + text::Text }; use embedded_plots::{axis::Scale, curve::Curve, single_plot::SinglePlot}; use profont::{ - PROFONT_12_POINT, PROFONT_14_POINT, PROFONT_24_POINT, PROFONT_7_POINT, PROFONT_9_POINT, + PROFONT_14_POINT, PROFONT_24_POINT, PROFONT_7_POINT, PROFONT_9_POINT, }; use ssd1675::Color; -use std::error::Error; use std::fmt::Debug; +use crate::error::display_no_data; mod api; mod data; @@ -119,30 +119,3 @@ where .expect("Failed to draw the temperature plot"); } -fn display_no_data(display: &mut D, err: E) -where - D: DrawTarget, - D::Error: Debug, - E: Error, -{ - Text::with_alignment( - "KEINE DATEN", - Point::new(106, 28), - MonoTextStyle::new(&PROFONT_24_POINT, Color::Red), - Alignment::Center, - ) - .draw(display) - .expect("error drawing text"); - - eprintln!("[error] Failed to get/parse JSON:\n{}", err); - - // TODO(feliix42): As soon as there is a custom text renderer for full texts, use it here! - Text::with_baseline( - &err.to_string(), - Point::new(5, 32), - MonoTextStyle::new(&PROFONT_12_POINT, Color::Black), - Baseline::Top, - ) - .draw(display) - .expect("error drawing text"); -}