1use std::fmt;
145use std::sync::{Arc, Mutex};
146use std::time::Instant;
147
148use chrono::{DateTime, Utc};
149use sunrise::{SolarDay, SolarEvent};
150
151use crate::formatting::Format;
152
153use super::prelude::*;
154
155pub mod met_no;
156pub mod nws;
157pub mod open_weather_map;
158
159const IP_API_URL: &str = "https://ipapi.co/json";
160
161static LAST_AUTOLOCATE: Mutex<Option<AutolocateResult>> = Mutex::new(None);
162
163#[derive(Deserialize, Debug)]
164#[serde(deny_unknown_fields)]
165pub struct Config {
166 #[serde(default = "default_interval")]
167 pub interval: Seconds,
168 #[serde(default)]
169 pub format: FormatConfig,
170 pub format_alt: Option<FormatConfig>,
171 pub service: WeatherService,
172 #[serde(default)]
173 pub autolocate: bool,
174 pub autolocate_interval: Option<Seconds>,
175}
176
177fn default_interval() -> Seconds {
178 Seconds::new(600)
179}
180
181#[async_trait]
182trait WeatherProvider {
183 async fn get_weather(
184 &self,
185 autolocated_location: Option<&Coordinates>,
186 need_forecast: bool,
187 ) -> Result<WeatherResult>;
188}
189
190#[derive(Deserialize, Debug)]
191#[serde(tag = "name", rename_all = "lowercase")]
192pub enum WeatherService {
193 OpenWeatherMap(open_weather_map::Config),
194 MetNo(met_no::Config),
195 Nws(nws::Config),
196}
197
198#[derive(Clone, Copy, Default)]
199enum WeatherIcon {
200 Clear {
201 is_night: bool,
202 },
203 Clouds {
204 is_night: bool,
205 },
206 Fog {
207 is_night: bool,
208 },
209 Rain {
210 is_night: bool,
211 },
212 Snow,
213 Thunder {
214 is_night: bool,
215 },
216 #[default]
217 Default,
218}
219
220impl WeatherIcon {
221 fn to_icon_str(self) -> &'static str {
222 match self {
223 Self::Clear { is_night: false } => "weather_sun",
224 Self::Clear { is_night: true } => "weather_moon",
225 Self::Clouds { is_night: false } => "weather_clouds",
226 Self::Clouds { is_night: true } => "weather_clouds_night",
227 Self::Fog { is_night: false } => "weather_fog",
228 Self::Fog { is_night: true } => "weather_fog_night",
229 Self::Rain { is_night: false } => "weather_rain",
230 Self::Rain { is_night: true } => "weather_rain_night",
231 Self::Snow => "weather_snow",
232 Self::Thunder { is_night: false } => "weather_thunder",
233 Self::Thunder { is_night: true } => "weather_thunder_night",
234 Self::Default => "weather_default",
235 }
236 }
237}
238
239#[derive(Default)]
240struct WeatherMoment {
241 icon: WeatherIcon,
242 weather: String,
243 weather_verbose: String,
244 temp: f64,
245 apparent: f64,
246 humidity: f64,
247 wind: f64,
248 wind_kmh: f64,
249 wind_direction: Option<f64>,
250}
251
252struct ForecastAggregate {
253 temp: f64,
254 apparent: f64,
255 humidity: f64,
256 wind: f64,
257 wind_kmh: f64,
258 wind_direction: Option<f64>,
259}
260
261struct ForecastAggregateSegment {
262 temp: Option<f64>,
263 apparent: Option<f64>,
264 humidity: Option<f64>,
265 wind: Option<f64>,
266 wind_kmh: Option<f64>,
267 wind_direction: Option<f64>,
268}
269
270struct WeatherResult {
271 location: String,
272 current_weather: WeatherMoment,
273 forecast: Option<Forecast>,
274 sunrise: DateTime<Utc>,
275 sunset: DateTime<Utc>,
276}
277
278impl WeatherResult {
279 fn into_values(self) -> Values {
280 let mut values = map! {
281 "location" => Value::text(self.location),
282 "icon" => Value::icon(self.current_weather.icon.to_icon_str()),
284 "temp" => Value::degrees(self.current_weather.temp),
285 "apparent" => Value::degrees(self.current_weather.apparent),
286 "humidity" => Value::percents(self.current_weather.humidity),
287 "weather" => Value::text(self.current_weather.weather),
288 "weather_verbose" => Value::text(self.current_weather.weather_verbose),
289 "wind" => Value::number(self.current_weather.wind),
290 "wind_kmh" => Value::number(self.current_weather.wind_kmh),
291 "direction" => Value::text(convert_wind_direction(self.current_weather.wind_direction).into()),
292 "sunrise" => Value::datetime(self.sunrise, None),
293 "sunset" => Value::datetime(self.sunset, None),
294 };
295
296 if let Some(forecast) = self.forecast {
297 macro_rules! map_forecasts {
298 ({$($suffix: literal => $src: expr),* $(,)?}) => {
299 map!{ @extend values
300 $(
301 concat!("temp_f", $suffix) => Value::degrees($src.temp),
302 concat!("apparent_f", $suffix) => Value::degrees($src.apparent),
303 concat!("humidity_f", $suffix) => Value::percents($src.humidity),
304 concat!("wind_f", $suffix) => Value::number($src.wind),
305 concat!("wind_kmh_f", $suffix) => Value::number($src.wind_kmh),
306 concat!("direction_f", $suffix) => Value::text(convert_wind_direction($src.wind_direction).into()),
307 )*
308 }
309 };
310 }
311 map_forecasts!({
312 "avg" => forecast.avg,
313 "min" => forecast.min,
314 "max" => forecast.max,
315 "fin" => forecast.fin,
316 });
317
318 map! { @extend values
319 "icon_ffin" => Value::icon(forecast.fin.icon.to_icon_str()),
320 "weather_ffin" => Value::text(forecast.fin.weather.clone()),
321 "weather_verbose_ffin" => Value::text(forecast.fin.weather_verbose.clone()),
322 }
323 }
324
325 values
326 }
327}
328
329struct Forecast {
330 avg: ForecastAggregate,
331 min: ForecastAggregate,
332 max: ForecastAggregate,
333 fin: WeatherMoment,
334}
335
336impl Forecast {
337 fn new(data: &[ForecastAggregateSegment], fin: WeatherMoment) -> Self {
338 let mut temp_avg = 0.0;
339 let mut temp_count = 0.0;
340 let mut apparent_avg = 0.0;
341 let mut apparent_count = 0.0;
342 let mut humidity_avg = 0.0;
343 let mut humidity_count = 0.0;
344 let mut wind_north_avg = 0.0;
345 let mut wind_east_avg = 0.0;
346 let mut wind_kmh_north_avg = 0.0;
347 let mut wind_kmh_east_avg = 0.0;
348 let mut wind_count = 0.0;
349 let mut max = ForecastAggregate {
350 temp: f64::MIN,
351 apparent: f64::MIN,
352 humidity: f64::MIN,
353 wind: f64::MIN,
354 wind_kmh: f64::MIN,
355 wind_direction: None,
356 };
357 let mut min = ForecastAggregate {
358 temp: f64::MAX,
359 apparent: f64::MAX,
360 humidity: f64::MAX,
361 wind: f64::MAX,
362 wind_kmh: f64::MAX,
363 wind_direction: None,
364 };
365 for val in data {
366 if let Some(temp) = val.temp {
367 temp_avg += temp;
368 max.temp = max.temp.max(temp);
369 min.temp = min.temp.min(temp);
370 temp_count += 1.0;
371 }
372 if let Some(apparent) = val.apparent {
373 apparent_avg += apparent;
374 max.apparent = max.apparent.max(apparent);
375 min.apparent = min.apparent.min(apparent);
376 apparent_count += 1.0;
377 }
378 if let Some(humidity) = val.humidity {
379 humidity_avg += humidity;
380 max.humidity = max.humidity.max(humidity);
381 min.humidity = min.humidity.min(humidity);
382 humidity_count += 1.0;
383 }
384
385 if let (Some(wind), Some(wind_kmh)) = (val.wind, val.wind_kmh) {
386 if let Some(degrees) = val.wind_direction {
387 let (sin, cos) = degrees.to_radians().sin_cos();
388 wind_north_avg += wind * cos;
389 wind_east_avg += wind * sin;
390 wind_kmh_north_avg += wind_kmh * cos;
391 wind_kmh_east_avg += wind_kmh * sin;
392 wind_count += 1.0;
393 }
394
395 if wind > max.wind {
396 max.wind_direction = val.wind_direction;
397 max.wind = wind;
398 max.wind_kmh = wind_kmh;
399 }
400
401 if wind < min.wind {
402 min.wind_direction = val.wind_direction;
403 min.wind = wind;
404 min.wind_kmh = wind_kmh;
405 }
406 }
407 }
408
409 temp_avg /= temp_count;
410 humidity_avg /= humidity_count;
411 apparent_avg /= apparent_count;
412
413 let (wind_avg, wind_kmh_avg, wind_direction_avg) = if wind_count == 0.0 {
415 (0.0, 0.0, None)
416 } else {
417 (
418 wind_east_avg.hypot(wind_north_avg) / wind_count,
419 wind_kmh_east_avg.hypot(wind_kmh_north_avg) / wind_count,
420 Some(
421 wind_east_avg
422 .atan2(wind_north_avg)
423 .to_degrees()
424 .rem_euclid(360.0),
425 ),
426 )
427 };
428
429 let avg = ForecastAggregate {
430 temp: temp_avg,
431 apparent: apparent_avg,
432 humidity: humidity_avg,
433 wind: wind_avg,
434 wind_kmh: wind_kmh_avg,
435 wind_direction: wind_direction_avg,
436 };
437 Self { avg, min, max, fin }
438 }
439}
440
441pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
442 let mut actions = api.get_actions()?;
443 api.set_default_actions(&[(MouseButton::Left, None, "toggle_format")])?;
444
445 let mut format = config.format.with_default(" $icon $weather $temp ")?;
446 let mut format_alt = match &config.format_alt {
447 Some(f) => Some(f.with_default("")?),
448 None => None,
449 };
450
451 let provider: Box<dyn WeatherProvider + Send + Sync> = match &config.service {
452 WeatherService::MetNo(service_config) => Box::new(met_no::Service::new(service_config)?),
453 WeatherService::OpenWeatherMap(service_config) => {
454 Box::new(open_weather_map::Service::new(config.autolocate, service_config).await?)
455 }
456 WeatherService::Nws(service_config) => {
457 Box::new(nws::Service::new(config.autolocate, service_config).await?)
458 }
459 };
460
461 let autolocate_interval = config.autolocate_interval.unwrap_or(config.interval);
462 let need_forecast = need_forecast(&format, format_alt.as_ref());
463
464 let mut timer = config.interval.timer();
465
466 loop {
467 let location = if config.autolocate {
468 let fetch = || find_ip_location(autolocate_interval.0);
469 Some(fetch.retry(ExponentialBuilder::default()).await?)
470 } else {
471 None
472 };
473
474 let fetch = || provider.get_weather(location.as_ref(), need_forecast);
475 let data = fetch.retry(ExponentialBuilder::default()).await?;
476 let data_values = data.into_values();
477
478 loop {
479 let mut widget = Widget::new().with_format(format.clone());
480 widget.set_values(data_values.clone());
481 api.set_widget(widget)?;
482
483 select! {
484 _ = timer.tick() => break,
485 _ = api.wait_for_update_request() => break,
486 Some(action) = actions.recv() => match action.as_ref() {
487 "toggle_format" => {
488 if let Some(ref mut format_alt) = format_alt {
489 std::mem::swap(format_alt, &mut format);
490 }
491 }
492 _ => (),
493 }
494 }
495 }
496 }
497}
498
499fn need_forecast(format: &Format, format_alt: Option<&Format>) -> bool {
500 fn has_forecast_key(format: &Format) -> bool {
501 macro_rules! format_suffix {
502 ($($suffix: literal),* $(,)?) => {
503 false
504 $(
505 || format.contains_key(concat!("temp_f", $suffix))
506 || format.contains_key(concat!("apparent_f", $suffix))
507 || format.contains_key(concat!("humidity_f", $suffix))
508 || format.contains_key(concat!("wind_f", $suffix))
509 || format.contains_key(concat!("wind_kmh_f", $suffix))
510 || format.contains_key(concat!("direction_f", $suffix))
511 )*
512 };
513 }
514
515 format_suffix!("avg", "min", "max", "fin")
516 || format.contains_key("icon_ffin")
517 || format.contains_key("weather_ffin")
518 || format.contains_key("weather_verbose_ffin")
519 }
520 has_forecast_key(format) || format_alt.is_some_and(has_forecast_key)
521}
522
523fn calculate_sunrise_sunset(
524 lat: f64,
525 lon: f64,
526 altitude: Option<f64>,
527) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
528 let date = Utc::now().date_naive();
529 let coordinates = sunrise::Coordinates::new(lat, lon).error("Invalid coordinates")?;
530 let solar_day = SolarDay::new(coordinates, date).with_altitude(altitude.unwrap_or_default());
531
532 Ok((
533 solar_day.event_time(SolarEvent::Sunrise),
534 solar_day.event_time(SolarEvent::Sunset),
535 ))
536}
537
538#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, SmartDefault)]
539#[serde(rename_all = "lowercase")]
540enum UnitSystem {
541 #[default]
542 Metric,
543 Imperial,
544}
545
546#[derive(Deserialize, Clone)]
547struct Coordinates {
548 latitude: f64,
549 longitude: f64,
550 city: String,
551}
552
553struct AutolocateResult {
554 location: Coordinates,
555 timestamp: Instant,
556}
557
558async fn find_ip_location(interval: Duration) -> Result<Coordinates> {
561 {
562 let guard = LAST_AUTOLOCATE.lock().unwrap();
563 if let Some(cached) = &*guard {
564 if cached.timestamp.elapsed() < interval {
565 return Ok(cached.location.clone());
566 }
567 }
568 }
569
570 #[derive(Deserialize)]
571 struct ApiResponse {
572 #[serde(flatten)]
573 location: Option<Coordinates>,
574 #[serde(default)]
575 error: bool,
576 #[serde(default)]
577 reason: ApiError,
578 }
579
580 #[derive(Deserialize, Default, Debug)]
581 #[serde(transparent)]
582 struct ApiError(Option<String>);
583
584 impl fmt::Display for ApiError {
585 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
586 f.write_str(self.0.as_deref().unwrap_or("Unknown Error"))
587 }
588 }
589 impl StdError for ApiError {}
590
591 let response: ApiResponse = REQWEST_CLIENT
592 .get(IP_API_URL)
593 .send()
594 .await
595 .error("Failed during request for current location")?
596 .json()
597 .await
598 .error("Failed while parsing location API result")?;
599
600 let location = if response.error {
601 return Err(Error {
602 message: Some("ipapi.co error".into()),
603 cause: Some(Arc::new(response.reason)),
604 });
605 } else {
606 response
607 .location
608 .error("Failed while parsing location API result")?
609 };
610
611 {
612 let mut guard = LAST_AUTOLOCATE.lock().unwrap();
613 *guard = Some(AutolocateResult {
614 location: location.clone(),
615 timestamp: Instant::now(),
616 });
617 }
618
619 Ok(location)
620}
621
622fn convert_wind_direction(direction_opt: Option<f64>) -> &'static str {
624 match direction_opt {
625 Some(direction) => match direction.round() as i64 {
626 24..=68 => "NE",
627 69..=113 => "E",
628 114..=158 => "SE",
629 159..=203 => "S",
630 204..=248 => "SW",
631 249..=293 => "W",
632 294..=338 => "NW",
633 _ => "N",
634 },
635 None => "-",
636 }
637}
638
639fn australian_apparent_temp(temp: f64, humidity: f64, wind_speed: f64) -> f64 {
641 let exponent = 17.27 * temp / (237.7 + temp);
642 let water_vapor_pressure = humidity * 0.06105 * exponent.exp();
643 temp + 0.33 * water_vapor_pressure - 0.7 * wind_speed - 4.0
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649
650 #[test]
651 fn test_new_forecast_average_wind_speed() {
652 let mut degrees = 0.0;
653 while degrees < 360.0 {
654 let forecast = Forecast::new(
655 &[
656 ForecastAggregateSegment {
657 temp: None,
658 apparent: None,
659 humidity: None,
660 wind: Some(1.0),
661 wind_kmh: Some(3.6),
662 wind_direction: Some(degrees),
663 },
664 ForecastAggregateSegment {
665 temp: None,
666 apparent: None,
667 humidity: None,
668 wind: Some(2.0),
669 wind_kmh: Some(7.2),
670 wind_direction: Some(degrees),
671 },
672 ],
673 WeatherMoment::default(),
674 );
675 assert!((forecast.avg.wind - 1.5).abs() < 0.1);
676 assert!((forecast.avg.wind_kmh - 5.4).abs() < 0.1);
677 assert!((forecast.avg.wind_direction.unwrap() - degrees).abs() < 0.1);
678
679 degrees += 15.0;
680 }
681 }
682
683 #[test]
684 fn test_new_forecast_average_wind_degrees() {
685 let mut degrees = 0.0;
686 while degrees < 360.0 {
687 let low = degrees - 1.0;
688 let high = degrees + 1.0;
689 let forecast = Forecast::new(
690 &[
691 ForecastAggregateSegment {
692 temp: None,
693 apparent: None,
694 humidity: None,
695 wind: Some(1.0),
696 wind_kmh: Some(3.6),
697 wind_direction: Some(low),
698 },
699 ForecastAggregateSegment {
700 temp: None,
701 apparent: None,
702 humidity: None,
703 wind: Some(1.0),
704 wind_kmh: Some(3.6),
705 wind_direction: Some(high),
706 },
707 ],
708 WeatherMoment::default(),
709 );
710 assert!((forecast.avg.wind_direction.unwrap() - degrees).abs() < 0.1);
713
714 degrees += 15.0;
715 }
716 }
717
718 #[test]
719 fn test_new_forecast_average_wind_speed_and_degrees() {
720 let mut degrees = 0.0;
721 while degrees < 360.0 {
722 let low = degrees - 1.0;
723 let high = degrees + 1.0;
724 let forecast = Forecast::new(
725 &[
726 ForecastAggregateSegment {
727 temp: None,
728 apparent: None,
729 humidity: None,
730 wind: Some(1.0),
731 wind_kmh: Some(3.6),
732 wind_direction: Some(low),
733 },
734 ForecastAggregateSegment {
735 temp: None,
736 apparent: None,
737 humidity: None,
738 wind: Some(2.0),
739 wind_kmh: Some(7.2),
740 wind_direction: Some(high),
741 },
742 ],
743 WeatherMoment::default(),
744 );
745 assert!((low + high) / 2.0 < forecast.avg.wind_direction.unwrap());
750 assert!(forecast.avg.wind_direction.unwrap() < high);
751 degrees += 15.0;
752 }
753 }
754}