show waterlevel for the next days #466

Merged
philipp merged 39 commits from show-waterlevel into main 2024-05-16 21:32:24 +02:00
14 changed files with 572 additions and 5 deletions

117
Cargo.lock generated
View File

@ -485,6 +485,26 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3855a8a784b474f333699ef2bbca9db2c4a1f6d9088a90a2d25b1eb53111eaa"
dependencies = [
"cfg-if",
]
[[package]]
name = "cron"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07"
dependencies = [
"chrono",
"nom",
"once_cell",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.12"
@ -774,6 +794,16 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6"
[[package]]
name = "flate2"
version = "1.0.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "flume"
version = "0.11.0"
@ -1301,6 +1331,17 @@ version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
[[package]]
name = "job_scheduler_ng"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87c252207f323e2996d087759ebdcff8f608cd3eaa9896909a0c2dd3050a3c6a"
dependencies = [
"chrono",
"cron",
"uuid",
]
[[package]]
name = "js-sys"
version = "0.3.69"
@ -2227,6 +2268,7 @@ dependencies = [
"futures",
"ics",
"itertools",
"job_scheduler_ng",
"lettre",
"log",
"openssl",
@ -2236,6 +2278,7 @@ dependencies = [
"serde_json",
"sqlx",
"tera",
"ureq",
]
[[package]]
@ -2284,10 +2327,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d5a6813c0759e4609cd494e8e725babae6a2ca7b62a5536a13daaec6fcb7ba"
dependencies = [
"ring",
"rustls-webpki",
"rustls-webpki 0.101.7",
"sct",
]
[[package]]
name = "rustls"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432"
dependencies = [
"log",
"ring",
"rustls-pki-types",
"rustls-webpki 0.102.3",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pemfile"
version = "1.0.4"
@ -2297,6 +2354,12 @@ dependencies = [
"base64 0.21.7",
]
[[package]]
name = "rustls-pki-types"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "beb461507cee2c2ff151784c52762cf4d9ff6a61f3e80968600ed24fa837fa54"
[[package]]
name = "rustls-webpki"
version = "0.101.7"
@ -2307,6 +2370,17 @@ dependencies = [
"untrusted",
]
[[package]]
name = "rustls-webpki"
version = "0.102.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3bce581c0dd41bce533ce695a1437fa16a7ab5ac3ccfa99fe1a620a7885eabf"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.15"
@ -2590,7 +2664,7 @@ dependencies = [
"once_cell",
"paste",
"percent-encoding",
"rustls",
"rustls 0.21.10",
"rustls-pemfile",
"serde",
"serde_json",
@ -2603,7 +2677,7 @@ dependencies = [
"tokio-stream",
"tracing",
"url",
"webpki-roots",
"webpki-roots 0.25.4",
]
[[package]]
@ -3232,6 +3306,25 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.9.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d11a831e3c0b56e438a28308e7c810799e3c118417f342d30ecec080105395cd"
dependencies = [
"base64 0.22.0",
"flate2",
"log",
"once_cell",
"rustls 0.22.4",
"rustls-pki-types",
"rustls-webpki 0.102.3",
"serde",
"serde_json",
"url",
"webpki-roots 0.26.1",
]
[[package]]
name = "url"
version = "2.5.0"
@ -3255,6 +3348,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
[[package]]
name = "uuid"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0"
dependencies = [
"getrandom",
]
[[package]]
name = "valuable"
version = "0.1.0"
@ -3364,6 +3466,15 @@ version = "0.25.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
[[package]]
name = "webpki-roots"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3de34ae270483955a94f4b21bdaaeb83d508bb84a01435f393818edb0012009"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "whoami"
version = "1.5.1"

View File

@ -25,6 +25,8 @@ futures = "0.3"
lettre = "0.11"
csv = "1.3"
itertools = "0.12"
job_scheduler_ng = "2.0"
ureq = { version = "2.9", features = ["json"] }
[target.'cfg(not(windows))'.dependencies]
openssl = { version = "0.10", features = [ "vendored" ] }

View File

@ -4,3 +4,4 @@ rss_key = "rss-key-for-ci"
limits = { file = "10 MiB", data-form = "10 MiB"}
smtp_pw = "8kIjlLH79Ky6D3jQ"
usage_log_path = "./usage.txt"
openweathermap_key = "c8dab8f91b5b815d76e9879cbaecd8d5"

View File

@ -175,3 +175,22 @@ CREATE TABLE IF NOT EXISTS "boat_reservation" (
"created_at" datetime not null default CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS "waterlevel" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"day" DATE NOT NULL,
"time" TEXT NOT NULL,
"max" INTEGER NOT NULL,
"min" INTEGER NOT NULL,
"mittel" INTEGER NOT NULL,
"tumax" INTEGER NOT NULL,
"tumin" INTEGER NOT NULL,
"tumittel" INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS "weather" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"day" DATE NOT NULL,
"max_temp" FLOAT NOT NULL,
"wind_gust" FLOAT NOT NULL,
"rain_mm" FLOAT NOT NULL
);

View File

@ -8,6 +8,8 @@ pub mod tera;
#[cfg(feature = "rest")]
pub mod rest;
pub mod scheduled;
#[cfg(test)]
#[macro_export]
macro_rules! testdb {

View File

@ -6,6 +6,7 @@ use std::str::FromStr;
use rot::rest;
#[cfg(feature = "rowing-tera")]
use rot::tera;
use rot::{scheduled, tera::Config};
use sqlx::{pool::PoolOptions, sqlite::SqliteConnectOptions, ConnectOptions};
@ -26,7 +27,7 @@ async fn rocket() -> _ {
.await
.unwrap();
let rocket = rocket::build().manage(db);
let rocket = rocket::build().manage(db.clone());
#[cfg(feature = "rowing-tera")]
let rocket = tera::config(rocket);
@ -34,5 +35,11 @@ async fn rocket() -> _ {
#[cfg(feature = "rest")]
let rocket = rest::config(rocket);
let config: Config = rocket
.figment()
.extract()
.expect("Config extraction failed");
scheduled::schedule(&db, &config);
rocket
}

View File

@ -5,6 +5,8 @@ use sqlx::SqlitePool;
use self::{
planned_event::{PlannedEvent, PlannedEventWithUserAndTriptype},
trip::{Trip, TripWithUserAndType},
waterlevel::Waterlevel,
weather::Weather,
};
pub mod boat;
@ -27,6 +29,8 @@ pub mod tripdetails;
pub mod triptype;
pub mod user;
pub mod usertrip;
pub mod waterlevel;
pub mod weather;
#[derive(Serialize, Debug)]
pub struct Day {
@ -34,6 +38,8 @@ pub struct Day {
planned_events: Vec<PlannedEventWithUserAndTriptype>,
trips: Vec<TripWithUserAndType>,
is_pinned: bool,
max_waterlevel: Option<i64>,
weather: Option<Weather>,
}
impl Day {
@ -44,6 +50,8 @@ impl Day {
planned_events: PlannedEvent::get_pinned_for_day(db, day).await,
trips: Trip::get_pinned_for_day(db, day).await,
is_pinned,
max_waterlevel: Waterlevel::max_waterlevel_for_day(db, day).await,
weather: Weather::find_by_day(db, day).await,
}
} else {
Self {
@ -51,6 +59,8 @@ impl Day {
planned_events: PlannedEvent::get_for_day(db, day).await,
trips: Trip::get_for_day(db, day).await,
is_pinned,
max_waterlevel: Waterlevel::max_waterlevel_for_day(db, day).await,
weather: Weather::find_by_day(db, day).await,
}
}
}

72
src/model/waterlevel.rs Normal file
View File

@ -0,0 +1,72 @@
use std::ops::DerefMut;
use chrono::NaiveDate;
use rocket::serde::{Deserialize, Serialize};
use sqlx::{FromRow, Sqlite, SqlitePool, Transaction};
#[derive(FromRow, Debug, Serialize, Deserialize, Eq, Hash, PartialEq, Clone)]
pub struct Waterlevel {
pub id: i64,
pub day: NaiveDate,
pub time: String,
pub max: i64,
pub min: i64,
pub mittel: i64,
pub tumax: i64,
pub tumin: i64,
pub tumittel: i64,
}
impl Waterlevel {
pub async fn find_by_id(db: &SqlitePool, id: i32) -> Option<Self> {
sqlx::query_as!(Self, "SELECT * FROM waterlevel WHERE id like ?", id)
.fetch_one(db)
.await
.ok()
}
pub async fn find_by_id_tx(db: &mut Transaction<'_, Sqlite>, id: i32) -> Option<Self> {
sqlx::query_as!(Self, "SELECT * FROM waterlevel WHERE id like ?", id)
.fetch_one(db.deref_mut())
.await
.ok()
}
pub async fn create(
db: &mut Transaction<'_, Sqlite>,
day: NaiveDate,
time: String,
max: i64,
min: i64,
mittel: i64,
tumax: i64,
tumin: i64,
tumittel: i64,
) -> Result<(), String> {
sqlx::query!(
"INSERT INTO waterlevel(day, time, max, min, mittel, tumax, tumin, tumittel) VALUES (?,?,?,?,?,?,?,?)",
day, time, max, min, mittel, tumax, tumin, tumittel
)
.execute(db.deref_mut())
.await
.map_err(|e| e.to_string())?;
Ok(())
}
pub async fn max_waterlevel_for_day(db: &SqlitePool, day: NaiveDate) -> Option<i64> {
sqlx::query!(
"SELECT MAX(mittel) as max FROM waterlevel WHERE day = ?",
day
)
.fetch_one(db)
.await
.unwrap()
.max
}
pub async fn delete_all(db: &mut Transaction<'_, Sqlite>) {
sqlx::query!("DELETE FROM waterlevel;")
.execute(db.deref_mut())
.await
.unwrap();
}
}

56
src/model/weather.rs Normal file
View File

@ -0,0 +1,56 @@
use std::ops::DerefMut;
use chrono::NaiveDate;
use rocket::serde::{Deserialize, Serialize};
use sqlx::{FromRow, Sqlite, SqlitePool, Transaction};
#[derive(FromRow, Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct Weather {
pub id: i64,
pub day: NaiveDate,
pub max_temp: f64,
pub wind_gust: f64,
pub rain_mm: f64,
}
impl Weather {
pub async fn find_by_day(db: &SqlitePool, day: NaiveDate) -> Option<Self> {
sqlx::query_as!(Self, "SELECT * FROM weather WHERE day = ?", day)
.fetch_one(db)
.await
.ok()
}
pub async fn find_by_id_tx(db: &mut Transaction<'_, Sqlite>, day: NaiveDate) -> Option<Self> {
sqlx::query_as!(Self, "SELECT * FROM weather WHERE day = ?", day)
.fetch_one(db.deref_mut())
.await
.ok()
}
pub async fn create(
db: &mut Transaction<'_, Sqlite>,
day: NaiveDate,
max_temp: f64,
wind_gust: f64,
rain_mm: f64,
) -> Result<(), String> {
sqlx::query!(
"INSERT INTO weather(day, max_temp, wind_gust, rain_mm) VALUES (?,?,?,?)",
day,
max_temp,
wind_gust,
rain_mm
)
.execute(db.deref_mut())
.await
.map_err(|e| e.to_string())?;
Ok(())
}
pub async fn delete_all(db: &mut Transaction<'_, Sqlite>) {
sqlx::query!("DELETE FROM weather;")
.execute(db.deref_mut())
.await
.unwrap();
}
}

43
src/scheduled/mod.rs Normal file
View File

@ -0,0 +1,43 @@
mod waterlevel;
mod weather;
use std::time::Duration;
use job_scheduler_ng::{Job, JobScheduler};
use rocket::tokio::{self, task, time};
use sqlx::SqlitePool;
use crate::tera::Config;
pub fn schedule(db: &SqlitePool, config: &Config) {
let db = db.clone();
let openweathermap_key = config.openweathermap_key.clone();
tokio::task::spawn(async {
waterlevel::update(&db).await.unwrap();
weather::update(&db, &openweathermap_key).await.unwrap();
let mut sched = JobScheduler::new();
// Every hour
sched.add(Job::new("0 0 * * * * *".parse().unwrap(), move || {
let db_clone = db.clone();
// Use block_in_place to run async code in the synchronous function; TODO: Make it
// nicer one's rust (stable) support async closures
task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
waterlevel::update(&db_clone).await.unwrap();
weather::update(&db_clone, &openweathermap_key)
.await
.unwrap();
});
});
}));
let mut interval = time::interval(Duration::from_secs(60));
loop {
sched.tick();
interval.tick().await;
}
});
}

112
src/scheduled/waterlevel.rs Normal file
View File

@ -0,0 +1,112 @@
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveTime};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use crate::model::waterlevel::Waterlevel;
pub async fn update(db: &SqlitePool) -> Result<(), String> {
let mut tx = db.begin().await.unwrap();
// 1. Delete water levels starting from yesterday
Waterlevel::delete_all(&mut tx).await;
// 2. Fetch
let station = fetch()?;
for d in station.data {
let (Some(max), Some(min), Some(mittel), Some(tumax), Some(tumin), Some(tumittel)) =
(d.max, d.min, d.mittel, d.tumax, d.tumin, d.tumittel)
else {
println!("Ignored invalid values: {d:?}");
continue;
};
let Ok(datetime): Result<DateTime<FixedOffset>, _> = d.timestamp.parse() else {
return Err("Failed to parse datetime from hydro json".into());
};
let date: NaiveDate = datetime.naive_utc().date();
// Extract time component and format as string
let time: NaiveTime = datetime.naive_utc().time();
let time_str = time.format("%H:%M").to_string();
Waterlevel::create(
&mut tx, date, time_str, max, min, mittel, tumax, tumin, tumittel,
)
.await?
}
// 3. Save in DB
tx.commit().await.unwrap();
Ok(())
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct Station {
station_no: String,
station_latitude: String,
station_longitude: String,
parametertype_name: String,
ts_shortname: String,
ts_name: String,
ts_unitname: String,
ts_unitsymbol: String,
ts_precision: String,
rows: String,
columns: String,
data: Vec<Data>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct Data {
timestamp: String,
max: Option<i64>,
min: Option<i64>,
mittel: Option<i64>,
tumax: Option<i64>,
tumin: Option<i64>,
tumittel: Option<i64>,
}
fn fetch() -> Result<Station, String> {
let url = "https://hydro.ooe.gv.at/daten/internet/stations/OG/207068/S/forecast.json";
match ureq::get(url).call() {
Ok(response) => {
let forecast: Result<Vec<Station>, _> = response.into_json();
if let Ok(data) = forecast {
if data.len() == 1 {
return Ok(data[0].clone());
} else {
return Err(format!(
"Expected 1 station (Linz); got {} while fetching from {url}. Maybe the hydro data format changed?",
data.len()
));
}
} else {
return Err(format!(
"Failed to parse the json received by {url}: {}",
forecast.err().unwrap()
));
}
}
Err(_) => {
return Err(format!(
"Could not fetch {url}, do you have internet? Maybe their server is down?"
));
}
}
}
//#[cfg(test)]
//mod test {
// use crate::testdb;
//
// use super::*;
// #[sqlx::test]
// fn test_fetch_succ() {
// let pool = testdb!();
// fetch();
// }
//}

120
src/scheduled/weather.rs Normal file
View File

@ -0,0 +1,120 @@
use chrono::DateTime;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use crate::model::weather::Weather;
pub async fn update(db: &SqlitePool, api_key: &str) -> Result<(), String> {
let mut tx = db.begin().await.unwrap();
// 1. Delete weather data
Weather::delete_all(&mut tx).await;
// 2. Fetch
let data = fetch(api_key)?;
for d in data.daily {
let Some(date) = DateTime::from_timestamp(d.dt, 0) else {
println!("Skipping {} because convertion to datetime failed", d.dt);
continue;
};
let max_temp = d.temp.max;
let wind_gust = d.wind_gust;
let rain_mm = d.rain.unwrap_or(0.);
Weather::create(
&mut tx,
date.naive_utc().into(),
max_temp,
wind_gust,
rain_mm,
)
.await?
}
// 3. Save in DB
tx.commit().await.unwrap();
Ok(())
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct Data {
lat: f64,
lon: f64,
timezone: String,
timezone_offset: i64,
daily: Vec<Daily>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct Daily {
dt: i64,
sunrise: i64,
sunset: i64,
moonrise: i64,
moonset: i64,
moon_phase: f64,
summary: String,
temp: Temp,
feels_like: FeelsLike,
pressure: i64,
humidity: i64,
dew_point: f64,
wind_speed: f64,
wind_deg: i64,
wind_gust: f64,
weather: Vec<DailyWeather>,
clouds: i64,
pop: f64,
rain: Option<f64>,
uvi: f64,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct Temp {
day: f64,
min: f64,
max: f64,
night: f64,
eve: f64,
morn: f64,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct FeelsLike {
day: f64,
night: f64,
eve: f64,
morn: f64,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct DailyWeather {
id: i64,
main: String,
description: String,
icon: String,
}
fn fetch(api_key: &str) -> Result<Data, String> {
let url = format!("https://api.openweathermap.org/data/3.0/onecall?lat=48.31970&lon=14.29451&units=metric&exclude=current,minutely,hourly,alert&appid={api_key}");
match ureq::get(&url).call() {
Ok(response) => {
let data: Result<Data, _> = response.into_json();
if let Ok(data) = data {
return Ok(data);
} else {
return Err(format!(
"Failed to parse the json received by {url}: {}",
data.err().unwrap()
));
}
}
Err(_) => {
return Err(format!(
"Could not fetch {url}, do you have internet? Maybe their server is down?"
));
}
}
}

View File

@ -166,6 +166,7 @@ pub struct Config {
rss_key: String,
smtp_pw: String,
usage_log_path: String,
pub openweathermap_key: String,
}
pub fn config(rocket: Rocket<Build>) -> Rocket<Build> {

View File

@ -91,8 +91,19 @@
<div>
<h2 class="font-bold uppercase tracking-wide text-center rounded-t-md {% if day.is_pinned %} text-white bg-primary-950 {% else %} text-primary-950 dark:text-white bg-gray-200 dark:bg-primary-950 bg-opacity-80 {% endif %} text-lg px-3 py-3 ">
{{ day.day| date(format="%d.%m.%Y") }}
<small class="inline-block ml-1 text-xs {% if day.is_pinned %} text-gray-200 {% else %} text-gray-500 dark:text-gray-100 {% endif %}">{{ day.day | date(format="%A", locale="de_AT") }}</small>
<small class="inline-block ml-1 text-xs {% if day.is_pinned %} text-gray-200 {% else %} text-gray-500 dark:text-gray-100 {% endif %}">{{ day.day | date(format="%A", locale="de_AT") }}
{% if day.max_waterlevel %}
&bullet; <a href="https://hydro.ooe.gv.at/#/overview/Wasserstand/station/16668/Linz/Wasserstand"
target="_blank"
title="Prognostizierter maximaler Wasserstand am {{ day.day | date(format="%A", locale="de_AT") }}: {{ day.max_waterlevel }} cm">🌊{{ day.max_waterlevel }} cm</a>
{% endif %}
</small>
</h2>
{% if day.weather %}
<div class="bg-gray-300 rounded text-center">
Max temp: {{ day.weather.max_temp | round }}° &bullet; Windböe: {{ day.weather.wind_gust | round }} km/h &bullet; Regen: {{ day.weather.rain_mm | round }} mm
</div>
{% endif %}
{% if day.planned_events | length > 0 or day.trips | length > 0 %}
<div class="grid grid-cols-1 gap-3 mb-3">
{# --- START Events --- #}