Add pollen indicator

This commit is contained in:
Felix Suchert 2023-05-29 11:52:40 +02:00
parent 0381b8d14c
commit 426d5b4453
Signed by: feliix42
GPG key ID: 24363525EA0E8A99
9 changed files with 463 additions and 157 deletions

View file

@ -8,10 +8,10 @@ authors = ["Felix Suchert <dev@felixsuchert.de>"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
embedded-graphics = "0.7.1" embedded-graphics = "0.8"
embedded-plots = "0.2.0" embedded-plots = "0.2"
tinybmp = "0.3.3" tinybmp = "0.5"
profont = "0.5.0" profont = "0.6"
ssd1675 = { git = "https://github.com/Feliix42/ssd1675" } ssd1675 = { git = "https://github.com/Feliix42/ssd1675" }
#ssd1675 = { path = "../ssd1675" } #ssd1675 = { path = "../ssd1675" }
@ -21,9 +21,11 @@ serde_json = "1.0"
# for retrieving weather data from the API # for retrieving weather data from the API
ureq = { version = "2.6", features = ["json"] } 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 } 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] [features]

View file

@ -1,6 +1,7 @@
use embedded_graphics::prelude::*; use embedded_graphics::prelude::*;
use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window}; use embedded_graphics_simulator::{OutputSettings, SimulatorDisplay, Window};
use ssd1675::Color; use ssd1675::Color;
use inky_ticker::ApplicationState;
fn main() -> Result<(), core::convert::Infallible> { fn main() -> Result<(), core::convert::Infallible> {
println!("Initialize Display"); println!("Initialize Display");
@ -9,18 +10,25 @@ fn main() -> Result<(), core::convert::Infallible> {
println!("Creating Window"); println!("Creating Window");
let mut window = Window::new("Hello World", &OutputSettings::default()); let mut window = Window::new("Hello World", &OutputSettings::default());
//loop { let mut state = ApplicationState::default();
//println!("Populate display...");
//inky_ticker::populate(&mut display);
//println!("Updating window"); // switch between states every few minutes
//window.update(&display); 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); println!("Populating screen");
window.show_static(&display);
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);
}
} }

38
src/error.rs Normal file
View file

@ -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<D, E>(display: &mut D, err: E)
where
D: DrawTarget<Color = Color>,
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");
}

View file

@ -1,121 +1,50 @@
use embedded_graphics::{ use embedded_graphics::draw_target::DrawTarget;
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 ssd1675::Color; use ssd1675::Color;
use std::fmt::Debug; use std::fmt::Debug;
use std::process::Command;
use std::{fs, io};
use tinybmp::Bmp;
mod error;
mod pollen;
mod weather; mod weather;
pub fn populate<D>(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<pollen::data::PollenData>,
/// 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<D>(display: &mut D, state: &mut ApplicationState)
where where
D: DrawTarget<Color = Color>, D: DrawTarget<Color = Color>,
D::Error: Debug, D::Error: Debug,
{ {
weather::get_weather(display) match state.current_screen {
Screen::Weather => weather::get_weather(display),
Screen::PollenRadar => pollen::get_pollen(display, state),
} }
// TODO(feliix42): Remove dead code here
pub fn populate_default<D>(display: &mut D)
where
D: DrawTarget<Color = Color>,
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");
}
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::<Color>::from_slice(bmp_data).unwrap();
//let bmp: Bmp<Color> = 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<f64, io::Error> {
fs::read_to_string("/sys/class/thermal/thermal_zone0/temp")?
.trim()
.parse::<i32>()
.map(|temp| temp as f64 / 1000.)
.map_err(|err| io::Error::new(io::ErrorKind::Other, err))
}
pub fn read_uptime() -> Option<String> {
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<String> {
Command::new("uname")
.arg("-smr")
.output()
.ok()
.and_then(|output| {
if output.status.success() {
String::from_utf8(output.stdout).ok()
} else {
None
}
})
} }

7
src/pollen/api.rs Normal file
View file

@ -0,0 +1,7 @@
use super::data::RawPollenData;
pub fn get_pollen_data() -> Result<RawPollenData, ureq::Error> {
let api_url = "https://opendata.dwd.de/climate_environment/health/alerts/s31fg.json";
Ok(ureq::get(api_url).call()?.into_json()?)
}

204
src/pollen/data.rs Normal file
View file

@ -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<String, String>,
// actual data
/// A list of all data for Germany
content: Vec<RawRegionPollenData>,
}
#[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<Allergene, RawAllergenDetails>,
}
#[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<RegionPollenData>,
}
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<RawPollenData> 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<Allergene, AllergenDetails>,
}
impl From<RawRegionPollenData> 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<RawAllergenDetails> 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<Self, Self::Err> {
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),
}
}
}

145
src/pollen/mod.rs Normal file
View file

@ -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<D>(display: &mut D, state: &mut ApplicationState)
where
D: DrawTarget<Color = Color>,
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);
}

View file

@ -59,7 +59,7 @@ impl WeatherData {
} }
pub fn current_condition(&self) -> Bmp<'static, Color> { pub fn current_condition(&self) -> Bmp<'static, Color> {
unimplemented!() self.current.data.weather[0].condition.clone().into()
} }
pub fn forecast_plot_data(&self) -> Vec<PlotPoint> { pub fn forecast_plot_data(&self) -> Vec<PlotPoint> {
@ -175,7 +175,7 @@ pub struct WeatherDetails {
/// specification](https://openweathermap.org/weather-conditions#Weather-Condition-Codes-2). /// specification](https://openweathermap.org/weather-conditions#Weather-Condition-Codes-2).
/// It ignores the fine details within weather conditions (e.g. heavy thunderstorm and light /// It ignores the fine details within weather conditions (e.g. heavy thunderstorm and light
/// thunderstorm both map to `Thunderstorm`). /// thunderstorm both map to `Thunderstorm`).
#[derive(Deserialize)] //Debug)] #[derive(Clone, Deserialize)] //Debug)]
#[serde(from = "u16")] #[serde(from = "u16")]
pub enum WeatherCondition { pub enum WeatherCondition {
Thunderstorm, Thunderstorm,

View file

@ -3,15 +3,15 @@ use embedded_graphics::{
image::Image, image::Image,
mono_font::MonoTextStyle, mono_font::MonoTextStyle,
prelude::*, prelude::*,
text::{Alignment, Baseline, Text}, text::Text
}; };
use embedded_plots::{axis::Scale, curve::Curve, single_plot::SinglePlot}; use embedded_plots::{axis::Scale, curve::Curve, single_plot::SinglePlot};
use profont::{ 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 ssd1675::Color;
use std::error::Error;
use std::fmt::Debug; use std::fmt::Debug;
use crate::error::display_no_data;
mod api; mod api;
mod data; mod data;
@ -119,30 +119,3 @@ where
.expect("Failed to draw the temperature plot"); .expect("Failed to draw the temperature plot");
} }
fn display_no_data<D, E>(display: &mut D, err: E)
where
D: DrawTarget<Color = Color>,
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");
}