Initial commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
1672
Cargo.lock
generated
Normal file
1672
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
Normal file
16
Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "temperature-numerics"
|
||||
version = "0.1.0"
|
||||
authors = ["Anders Olsson <anders.e.olsson@gmail.com>"]
|
||||
edition = "2018"
|
||||
|
||||
[dependencies]
|
||||
enum-map = "1.1"
|
||||
reqwest = "0.11"
|
||||
scraper = "0.12"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
strum = { version = "0.21", features = ["derive"] }
|
||||
thiserror = "1.0"
|
||||
tokio = { version = "1.7", features = ["macros", "rt-multi-thread", "sync", "time", "signal"] }
|
||||
warp = { version = "0.3", default-features = false }
|
||||
268
src/main.rs
Normal file
268
src/main.rs
Normal file
@@ -0,0 +1,268 @@
|
||||
use std::error::Error;
|
||||
|
||||
use tokio::signal;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn Error>> {
|
||||
let state = models::State::default();
|
||||
let routes = filters::api(state.clone());
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
let (_, server) =
|
||||
warp::serve(routes).bind_with_graceful_shutdown(([0, 0, 0, 0], 8080), async {
|
||||
rx.await.ok();
|
||||
});
|
||||
|
||||
tokio::spawn(tasks::update_temperature(state));
|
||||
tokio::spawn(server);
|
||||
|
||||
signal::ctrl_c().await?;
|
||||
|
||||
let _ = tx.send(());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
mod models {
|
||||
use std::ops::Deref;
|
||||
use std::sync::Arc;
|
||||
|
||||
use enum_map::EnumMap;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::api::Location;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct State(Arc<RwLock<EnumMap<Location, f32>>>);
|
||||
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self(Arc::new(RwLock::new(EnumMap::default())))
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for State {
|
||||
type Target = Arc<RwLock<EnumMap<Location, f32>>>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod filters {
|
||||
use warp::Filter;
|
||||
|
||||
use super::handlers;
|
||||
use super::models::State;
|
||||
|
||||
pub fn api(
|
||||
state: State,
|
||||
) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
|
||||
location(state)
|
||||
}
|
||||
|
||||
// GET /location/:location
|
||||
pub fn location(
|
||||
state: State,
|
||||
) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
|
||||
warp::path("location")
|
||||
.and(warp::get())
|
||||
.and(with_state(state))
|
||||
.and(warp::path::param())
|
||||
.and_then(handlers::location)
|
||||
}
|
||||
|
||||
pub fn with_state(
|
||||
state: State,
|
||||
) -> impl Filter<Extract = (State,), Error = std::convert::Infallible> + Clone {
|
||||
warp::any().map(move || state.clone())
|
||||
}
|
||||
}
|
||||
|
||||
mod handlers {
|
||||
use std::convert::Infallible;
|
||||
|
||||
use super::api::Location;
|
||||
use super::models::State;
|
||||
use super::numerics::Label;
|
||||
|
||||
pub async fn location(
|
||||
state: State,
|
||||
location: Location,
|
||||
) -> Result<impl warp::Reply, Infallible> {
|
||||
let locations = state.read().await;
|
||||
|
||||
let temperature = locations[location];
|
||||
|
||||
let label = Label::with_value(format!("{}", temperature));
|
||||
|
||||
Ok(warp::reply::json(&label))
|
||||
}
|
||||
}
|
||||
|
||||
mod tasks {
|
||||
use reqwest::Client;
|
||||
use strum::IntoEnumIterator;
|
||||
use tokio::time::{interval, Duration};
|
||||
|
||||
use super::api::*;
|
||||
use super::models::State;
|
||||
|
||||
pub async fn update_temperature(state: State) {
|
||||
let client = Client::new();
|
||||
let mut interval = interval(Duration::from_secs(60 * 5));
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
// TODO(anders): fetch in parallell
|
||||
for location in Location::iter() {
|
||||
match temperature_at(client.clone(), location).await {
|
||||
Ok(Some(temperature)) => {
|
||||
let mut write = state.write().await;
|
||||
|
||||
write[location] = temperature;
|
||||
}
|
||||
Ok(None) => {
|
||||
// TODO(anders): add logging for this
|
||||
}
|
||||
Err(_) => {
|
||||
// TODO(anders): backoff for a while
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod api {
|
||||
use enum_map::Enum;
|
||||
use reqwest::Client;
|
||||
use scraper::{Html, Selector};
|
||||
use serde::Deserialize;
|
||||
use strum::{EnumIter, EnumString};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("reqwest error")]
|
||||
Reqwest(#[from] reqwest::Error),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Enum, EnumIter, EnumString, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum Location {
|
||||
Mullsjon,
|
||||
Vattern,
|
||||
}
|
||||
|
||||
impl Location {
|
||||
fn id(&self) -> &str {
|
||||
match self {
|
||||
Self::Mullsjon => "21",
|
||||
Self::Vattern => "22",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn temperature_at(client: Client, location: Location) -> Result<Option<f32>, Error> {
|
||||
let response = client
|
||||
.post("https://portal.loggamera.se/PublicViews/OverviewInside")
|
||||
.form(&[("id", location.id())])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let html = response.text().await?;
|
||||
|
||||
let fragment = Html::parse_fragment(&html);
|
||||
let selector = Selector::parse(".display-value").unwrap();
|
||||
|
||||
Ok(fragment
|
||||
.select(&selector)
|
||||
.next()
|
||||
.map(|element| element.inner_html())
|
||||
.and_then(|value| {
|
||||
value
|
||||
.split_once('°')
|
||||
.and_then(|(raw, _)| raw.parse::<f32>().ok())
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
mod numerics {
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Color {
|
||||
Red,
|
||||
Blue,
|
||||
Green,
|
||||
Purple,
|
||||
Orange,
|
||||
MidnightBlue,
|
||||
Coffee,
|
||||
Burgundy,
|
||||
Wintergreen,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct Label {
|
||||
postfix: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
color: Option<Color>,
|
||||
data: Data,
|
||||
}
|
||||
|
||||
impl Label {
|
||||
pub fn with_value(value: impl Into<Value>) -> Self {
|
||||
Self {
|
||||
postfix: String::new(),
|
||||
color: None,
|
||||
data: Data {
|
||||
value: value.into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_postfix(&mut self, postfix: impl Into<String>) -> &mut Self {
|
||||
self.postfix = postfix.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_color(&mut self, color: Color) -> &mut Self {
|
||||
self.color = Some(color);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
struct Data {
|
||||
value: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Value {
|
||||
String(String),
|
||||
Number(i32),
|
||||
}
|
||||
|
||||
impl From<String> for Value {
|
||||
fn from(string: String) -> Self {
|
||||
Self::String(string)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i32> for Value {
|
||||
fn from(number: i32) -> Self {
|
||||
Self::Number(number)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user