use std::collections::HashMap;
use std::error::Error;
type Result<T> = std::result::Result<T, Box<dyn Error>>;
#[derive(Debug, Clone)]
pub(crate) struct TimeSeriesEntry {
pub time: String,
pub temperature: f64,
pub icon_1h: Option<String>,
pub icon_6h: Option<String>,
pub icon_12h: Option<String>,
}
#[derive(Debug, Clone)]
pub struct CurrentWeather {
pub timestamp: i64,
pub temperature: f64,
pub min_temperature: f64,
pub max_temperature: f64,
pub icon: u8,
}
#[derive(Debug, Clone, Copy)]
pub struct ForecastDay {
pub min_temperature: f64,
pub max_temperature: f64,
pub icon: u8,
}
fn metno_to_infinitime(symbol: &str) -> u8 {
let base = symbol
.strip_suffix("_day")
.or_else(|| symbol.strip_suffix("_night"))
.or_else(|| symbol.strip_suffix("_polarday"))
.unwrap_or(symbol);
match base {
"clearsky" => 0,
"fair" => 1,
"partlycloudy" => 1,
"cloudy" => 2,
"heavycloud" | "heavycloudy" => 3,
"rainshowers" | "lightrainshowers" | "lightrain" => 4,
"rain" | "heavyrain" | "heavyrainshowers" | "rainandthunder" => 5,
"thunder"
| "thundershowers"
| "rainshowersandthunder"
| "heavyrainshowersandthunder"
| "lightrainshowersandthunder" => 6,
"snow" | "heavysnow" | "snowshowers" | "lightsnow" | "lightsnowshowers"
| "heavysnowshowers" | "sleet" | "lightsleet" | "heavysleet" | "sleetshowers"
| "lightsleetshowers" | "heavysleetshowers" => 7,
"fog" | "mist" => 8,
_ => 2,
}
}
fn parse_metno_time(s: &str) -> Result<(i64, (i32, u32, u32))> {
let parse_err = || format!("invalid MET.NO timestamp: {s}");
let year: i32 = s[0..4].parse().map_err(|_| parse_err())?;
let month: u32 = s[5..7].parse().map_err(|_| parse_err())?;
let day: u32 = s[8..10].parse().map_err(|_| parse_err())?;
let hour: u32 = s[11..13].parse().map_err(|_| parse_err())?;
let min: u32 = s[14..16].parse().map_err(|_| parse_err())?;
let sec: u32 = s[17..19].parse().map_err(|_| parse_err())?;
let ts = days_from_civil(year, month, day) as i64 * 86400
+ hour as i64 * 3600
+ min as i64 * 60
+ sec as i64;
Ok((ts, (year, month, day)))
}
fn days_from_civil(y: i32, m: u32, d: u32) -> i32 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let m = if m <= 2 { m + 9 } else { m - 3 };
let doy = (153 * m as i32 + 2) / 5 + d as i32 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe - 719468
}
fn civil_from_days(z: i32) -> (i32, u32, u32) {
let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = (if m <= 2 { y + 1 } else { y }) as u32;
(y as i32, m as u32, d)
}
fn advance_date(date: (i32, u32, u32), days: i64) -> (i32, u32, u32) {
let z = days_from_civil(date.0, date.1, date.2) + days as i32;
civil_from_days(z)
}
pub(crate) fn process_current(entries: &[TimeSeriesEntry]) -> Result<CurrentWeather> {
let first = entries.first().ok_or("empty timeseries")?;
let (ts, today) = parse_metno_time(&first.time)?;
let temperature = first.temperature;
let mut min_temp = f64::MAX;
let mut max_temp = f64::MIN;
for entry in entries {
let (_, date) = parse_metno_time(&entry.time)?;
if date == today {
let t = entry.temperature;
if t < min_temp {
min_temp = t;
}
if t > max_temp {
max_temp = t;
}
}
}
if min_temp == f64::MAX {
min_temp = temperature;
max_temp = temperature;
}
let icon = first
.icon_1h
.as_deref()
.or_else(|| first.icon_6h.as_deref())
.or_else(|| first.icon_12h.as_deref())
.map(metno_to_infinitime)
.unwrap_or(2);
Ok(CurrentWeather {
timestamp: ts,
temperature,
min_temperature: min_temp,
max_temperature: max_temp,
icon,
})
}
pub(crate) fn process_forecast(entries: &[TimeSeriesEntry]) -> Result<[ForecastDay; 5]> {
if entries.is_empty() {
return Ok([ForecastDay {
min_temperature: 0.0,
max_temperature: 0.0,
icon: 0,
}; 5]);
}
let (_, base_date) = parse_metno_time(&entries[0].time)?;
let mut day_data: HashMap<(i32, u32, u32), (f64, f64, Vec<u8>)> = HashMap::new();
for entry in entries {
let (_, date) = parse_metno_time(&entry.time)?;
let day_num = days_between(base_date, date);
if day_num < 1 || day_num > 5 {
continue;
}
let t = entry.temperature;
let entry_icon = entry
.icon_12h
.as_deref()
.or_else(|| entry.icon_6h.as_deref())
.or_else(|| entry.icon_1h.as_deref())
.map(metno_to_infinitime)
.unwrap_or(2);
let data = day_data
.entry(date)
.or_insert_with(|| (f64::MAX, f64::MIN, Vec::new()));
if t < data.0 {
data.0 = t;
}
if t > data.1 {
data.1 = t;
}
data.2.push(entry_icon);
}
let mut forecast = [ForecastDay {
min_temperature: 0.0,
max_temperature: 0.0,
icon: 0,
}; 5];
for day_offset in 1..6 {
let target = advance_date(base_date, day_offset);
if let Some((min_t, max_t, icons)) = day_data.get(&target) {
let icon = most_common(icons).unwrap_or(2);
forecast[(day_offset - 1) as usize] = ForecastDay {
min_temperature: *min_t,
max_temperature: *max_t,
icon,
};
}
}
Ok(forecast)
}
fn days_between(a: (i32, u32, u32), b: (i32, u32, u32)) -> i64 {
(days_from_civil(b.0, b.1, b.2) - days_from_civil(a.0, a.1, a.2)) as i64
}
fn most_common(items: &[u8]) -> Option<u8> {
let counts = items.iter().fold(HashMap::new(), |mut acc, &item| {
*acc.entry(item).or_insert(0) += 1;
acc
});
counts
.into_iter()
.max_by_key(|&(_, count)| count)
.map(|(item, _)| item)
}
pub struct MetNoClient {}
impl MetNoClient {
pub fn new() -> Self {
Self {}
}
pub(crate) fn fetch(&self, lat: f64, lon: f64) -> Result<Vec<TimeSeriesEntry>> {
let url = format!(
"https://api.met.no/weatherapi/locationforecast/2.0/compact?lat={}&lon={}",
lat, lon
);
let body = ureq::get(&url)
.set("User-Agent", "InfiniTimeWeather/0.1")
.call()
.map_err(|e| format!("MET.NO request failed: {e}"))?
.into_string()
.map_err(|e| format!("failed to read response body: {e}"))?;
parse_timeseries(&body)
}
}
fn parse_timeseries(body: &str) -> Result<Vec<TimeSeriesEntry>> {
let mut i = 0;
let val = parse_value(body, &mut i)?;
extract_timeseries(val)
}
fn extract_timeseries(val: Val) -> Result<Vec<TimeSeriesEntry>> {
let fields = obj_val(val)?;
let mut entries = Vec::new();
for (k, v) in fields {
if k == "properties" {
let props = obj_val(v)?;
for (pk, pv) in props {
if pk == "timeseries" {
let arr = arr_val(pv)?;
for item in arr {
entries.push(extract_entry(item)?);
}
}
}
}
}
Ok(entries)
}
fn extract_entry(val: Val) -> Result<TimeSeriesEntry> {
let fields = obj_val(val)?;
let mut time = String::new();
let mut temperature = f64::NAN;
let mut icon_1h = None;
let mut icon_6h = None;
let mut icon_12h = None;
for (k, v) in fields {
match k.as_str() {
"time" => time = str_val(v),
"data" => {
if let Ok(data_fields) = obj_val(v) {
for (dk, dv) in data_fields {
match dk.as_str() {
"instant" => {
if let Ok(inst) = obj_val(dv) {
for (ik, iv) in inst {
if ik == "details" {
if let Ok(det) = obj_val(iv) {
for (detk, detv) in det {
if detk == "air_temperature" {
temperature = num_val(detv);
}
}
}
}
}
}
}
"next_1_hours" => icon_1h = extract_symbol(dv),
"next_6_hours" => icon_6h = extract_symbol(dv),
"next_12_hours" => icon_12h = extract_symbol(dv),
_ => {}
}
}
}
}
_ => {}
}
}
Ok(TimeSeriesEntry {
time,
temperature,
icon_1h,
icon_6h,
icon_12h,
})
}
fn extract_symbol(v: Val) -> Option<String> {
let fields = obj_val(v).ok()?;
for (k, v) in fields {
if k == "summary" {
let sf = obj_val(v).ok()?;
for (sk, sv) in sf {
if sk == "symbol_code" {
return Some(str_val(sv));
}
}
}
}
None
}
fn str_val(v: Val) -> String {
match v {
Val::Str(s) => s,
_ => String::new(),
}
}
fn num_val(v: Val) -> f64 {
match v {
Val::Num(n) => n,
_ => f64::NAN,
}
}
fn obj_val(v: Val) -> Result<Vec<(String, Val)>> {
match v {
Val::Obj(f) => Ok(f),
_ => Err("expected object".into()),
}
}
fn arr_val(v: Val) -> Result<Vec<Val>> {
match v {
Val::Arr(a) => Ok(a),
_ => Err("expected array".into()),
}
}
enum Val {
Str(String),
Num(f64),
Arr(Vec<Val>),
Obj(Vec<(String, Val)>),
Null,
}
fn skip_ws(s: &str, i: &mut usize) {
let bytes = s.as_bytes();
while *i < bytes.len() && bytes[*i].is_ascii_whitespace() {
*i += 1;
}
}
fn expect(s: &str, i: &mut usize, c: u8) -> Result<()> {
skip_ws(s, i);
if s.as_bytes().get(*i) != Some(&c) {
return Err(format!("expected '{}' at position {}", c as char, *i).into());
}
*i += 1;
Ok(())
}
fn parse_string(s: &str, i: &mut usize) -> Result<String> {
skip_ws(s, i);
let bytes = s.as_bytes();
if bytes.get(*i) != Some(&b'"') {
return Err(format!("expected string at position {}", *i).into());
}
*i += 1;
let mut result = String::new();
while *i < bytes.len() {
match bytes[*i] {
b'"' => {
*i += 1;
return Ok(result);
}
b'\\' => {
*i += 1;
if *i >= bytes.len() {
return Err("unterminated string escape".into());
}
match bytes[*i] {
b'"' => result.push('"'),
b'\\' => result.push('\\'),
b'/' => result.push('/'),
b'n' => result.push('\n'),
b'r' => result.push('\r'),
b't' => result.push('\t'),
b'u' => {
if *i + 4 >= bytes.len() {
return Err("truncated unicode escape".into());
}
let hex = &s[*i + 1..*i + 5];
let code =
u32::from_str_radix(hex, 16).map_err(|_| "invalid unicode escape")?;
if let Some(c) = char::from_u32(code) {
result.push(c);
}
*i += 4;
}
_ => return Err(format!("invalid escape: \\{}", bytes[*i] as char).into()),
}
*i += 1;
}
_ => {
result.push(bytes[*i] as char);
*i += 1;
}
}
}
Err("unterminated string".into())
}
fn parse_number(s: &str, i: &mut usize) -> Result<f64> {
skip_ws(s, i);
let bytes = s.as_bytes();
let start = *i;
if *i < bytes.len() && bytes[*i] == b'-' {
*i += 1;
}
while *i < bytes.len() && bytes[*i].is_ascii_digit() {
*i += 1;
}
if *i < bytes.len() && bytes[*i] == b'.' {
*i += 1;
while *i < bytes.len() && bytes[*i].is_ascii_digit() {
*i += 1;
}
}
if *i < bytes.len() && (bytes[*i] == b'e' || bytes[*i] == b'E') {
*i += 1;
if *i < bytes.len() && (bytes[*i] == b'+' || bytes[*i] == b'-') {
*i += 1;
}
while *i < bytes.len() && bytes[*i].is_ascii_digit() {
*i += 1;
}
}
s[start..*i]
.parse::<f64>()
.map_err(|_| format!("invalid number at position {}", start).into())
}
fn parse_value(s: &str, i: &mut usize) -> Result<Val> {
skip_ws(s, i);
let bytes = s.as_bytes();
match bytes.get(*i) {
Some(b'"') => parse_string(s, i).map(Val::Str),
Some(b'{') => parse_object(s, i),
Some(b'[') => parse_array(s, i),
Some(b'n') => {
if s[*i..].starts_with("null") {
*i += 4;
Ok(Val::Null)
} else {
Err(format!("unexpected token at position {}", *i).into())
}
}
Some(b't') => {
if s[*i..].starts_with("true") {
*i += 4;
Err("unexpected boolean in JSON".into())
} else {
Err(format!("unexpected token at position {}", *i).into())
}
}
Some(b'f') => {
if s[*i..].starts_with("false") {
*i += 5;
Err("unexpected boolean in JSON".into())
} else {
Err(format!("unexpected token at position {}", *i).into())
}
}
Some(_) => parse_number(s, i).map(Val::Num),
None => Err("unexpected end of input".into()),
}
}
fn parse_object(s: &str, i: &mut usize) -> Result<Val> {
skip_ws(s, i);
expect(s, i, b'{')?;
let mut fields = Vec::new();
loop {
skip_ws(s, i);
if s.as_bytes().get(*i) == Some(&b'}') {
*i += 1;
return Ok(Val::Obj(fields));
}
let key = parse_string(s, i)?;
expect(s, i, b':')?;
let value = parse_value(s, i)?;
fields.push((key, value));
skip_ws(s, i);
if s.as_bytes().get(*i) == Some(&b',') {
*i += 1;
}
}
}
fn parse_array(s: &str, i: &mut usize) -> Result<Val> {
skip_ws(s, i);
expect(s, i, b'[')?;
let mut items = Vec::new();
loop {
skip_ws(s, i);
if s.as_bytes().get(*i) == Some(&b']') {
*i += 1;
return Ok(Val::Arr(items));
}
items.push(parse_value(s, i)?);
skip_ws(s, i);
if s.as_bytes().get(*i) == Some(&b',') {
*i += 1;
}
}
}