1use chrono::{DateTime, Utc};
152use sunrise::{SolarDay, SolarEvent};
153
154use super::prelude::*;
155use crate::formatting::Format;
156pub(super) use crate::geolocator::IPAddressInfo;
157use crate::util::{celsius_to_fahrenheit, kmh_to_mph, kmh_to_mps};
158
159pub mod met_no;
160pub mod nws;
161pub mod open_weather_map;
162
163#[derive(Deserialize, Debug)]
164pub struct Config {
165 #[serde(default = "default_interval")]
166 pub interval: Seconds,
167 #[serde(flatten)]
168 pub formats: MaybeMultiFormatConfig,
169 pub service: WeatherService,
170 #[serde(default)]
171 pub autolocate: bool,
172 pub autolocate_interval: Option<Seconds>,
173 pub units: Option<UnitSystem>,
174}
175
176fn default_interval() -> Seconds {
177 Seconds::new(600)
178}
179
180#[async_trait]
181trait WeatherProvider {
182 async fn get_weather(
183 &self,
184 autolocated_location: Option<&IPAddressInfo>,
185 need_forecast: bool,
186 ) -> Result<WeatherResult>;
187}
188
189#[derive(Deserialize, Debug)]
190#[serde(tag = "name", rename_all = "lowercase")]
191pub enum WeatherService {
192 OpenWeatherMap(open_weather_map::Config),
193 MetNo(met_no::Config),
194 Nws(nws::Config),
195}
196
197#[derive(Clone, Copy, Default)]
198enum WeatherIcon {
199 Clear {
200 is_night: bool,
201 },
202 Clouds {
203 is_night: bool,
204 },
205 Fog {
206 is_night: bool,
207 },
208 Rain {
209 is_night: bool,
210 },
211 Snow,
212 Thunder {
213 is_night: bool,
214 },
215 #[default]
216 Default,
217}
218
219impl WeatherIcon {
220 const ALL_NAMES: [&'static str; 12] = [
224 icons::WEATHER_SUN,
225 icons::WEATHER_MOON,
226 icons::WEATHER_CLOUDS,
227 icons::WEATHER_CLOUDS_NIGHT,
228 icons::WEATHER_FOG,
229 icons::WEATHER_FOG_NIGHT,
230 icons::WEATHER_RAIN,
231 icons::WEATHER_RAIN_NIGHT,
232 icons::WEATHER_SNOW,
233 icons::WEATHER_THUNDER,
234 icons::WEATHER_THUNDER_NIGHT,
235 icons::WEATHER_DEFAULT,
236 ];
237
238 fn to_icon_str(self) -> &'static str {
239 match self {
240 Self::Clear { is_night: false } => icons::WEATHER_SUN,
241 Self::Clear { is_night: true } => icons::WEATHER_MOON,
242 Self::Clouds { is_night: false } => icons::WEATHER_CLOUDS,
243 Self::Clouds { is_night: true } => icons::WEATHER_CLOUDS_NIGHT,
244 Self::Fog { is_night: false } => icons::WEATHER_FOG,
245 Self::Fog { is_night: true } => icons::WEATHER_FOG_NIGHT,
246 Self::Rain { is_night: false } => icons::WEATHER_RAIN,
247 Self::Rain { is_night: true } => icons::WEATHER_RAIN_NIGHT,
248 Self::Snow => icons::WEATHER_SNOW,
249 Self::Thunder { is_night: false } => icons::WEATHER_THUNDER,
250 Self::Thunder { is_night: true } => icons::WEATHER_THUNDER_NIGHT,
251 Self::Default => icons::WEATHER_DEFAULT,
252 }
253 }
254}
255
256#[derive(Default)]
257struct WeatherMoment {
258 icon: WeatherIcon,
259 weather: String,
260 weather_verbose: String,
261 temp: f64,
262 apparent: f64,
263 humidity: f64,
264 wind_kmh: f64,
265 wind_direction: Option<f64>,
266}
267
268struct ForecastAggregate {
269 temp: f64,
270 apparent: f64,
271 humidity: f64,
272 wind_kmh: f64,
273 wind_direction: Option<f64>,
274}
275
276struct ForecastAggregateSegment {
277 temp: Option<f64>,
278 apparent: Option<f64>,
279 humidity: Option<f64>,
280 wind_kmh: Option<f64>,
281 wind_direction: Option<f64>,
282}
283
284struct WeatherResult {
285 location: String,
286 current_weather: WeatherMoment,
287 forecast: Option<Forecast>,
288 sunrise: Option<DateTime<Utc>>,
289 sunset: Option<DateTime<Utc>>,
290}
291
292impl WeatherResult {
293 fn into_values(self, unit_system: &UnitSystem) -> Values {
294 let mut values = map! {
295 "location" => Value::text(self.location),
296 "icon" => Value::icon(self.current_weather.icon.to_icon_str()),
298 "temp" => unit_system.temperature_value(self.current_weather.temp),
299 "apparent" => unit_system.temperature_value(self.current_weather.apparent),
300 "humidity" => Value::percents(self.current_weather.humidity),
301 "weather" => Value::text(self.current_weather.weather),
302 "weather_verbose" => Value::text(self.current_weather.weather_verbose),
303 "wind" => unit_system.wind_speed_value(self.current_weather.wind_kmh),
304 "wind_kmh" => Value::number(self.current_weather.wind_kmh),
305 "direction" => Value::text(convert_wind_direction(self.current_weather.wind_direction).into()),
306 [if let Some(sunrise) = self.sunrise] "sunrise" => Value::datetime(sunrise, None),
307 [if let Some(sunset) = self.sunset] "sunset" => Value::datetime(sunset, None),
308 };
309
310 if let Some(forecast) = self.forecast {
311 macro_rules! map_forecasts {
312 ({$($suffix: literal => $src: expr),* $(,)?}) => {
313 map!{ @extend values
314 $(
315 concat!("temp_f", $suffix) => unit_system.temperature_value($src.temp),
316 concat!("apparent_f", $suffix) => unit_system.temperature_value($src.apparent),
317 concat!("humidity_f", $suffix) => Value::percents($src.humidity),
318 concat!("wind_f", $suffix) => unit_system.wind_speed_value($src.wind_kmh),
319 concat!("wind_kmh_f", $suffix) => Value::number($src.wind_kmh),
320 concat!("direction_f", $suffix) => Value::text(convert_wind_direction($src.wind_direction).into()),
321 )*
322 }
323 };
324 }
325 map_forecasts!({
326 "avg" => forecast.avg,
327 "min" => forecast.min,
328 "max" => forecast.max,
329 "fin" => forecast.fin,
330 });
331
332 map! { @extend values
333 "icon_ffin" => Value::icon(forecast.fin.icon.to_icon_str()),
334 "weather_ffin" => Value::text(forecast.fin.weather.clone()),
335 "weather_verbose_ffin" => Value::text(forecast.fin.weather_verbose.clone()),
336 }
337 }
338
339 values
340 }
341}
342
343struct Forecast {
344 avg: ForecastAggregate,
345 min: ForecastAggregate,
346 max: ForecastAggregate,
347 fin: WeatherMoment,
348}
349
350impl Forecast {
351 fn new(data: &[ForecastAggregateSegment], fin: WeatherMoment) -> Self {
352 let mut temp_avg = 0.0;
353 let mut temp_count = 0.0;
354 let mut apparent_avg = 0.0;
355 let mut apparent_count = 0.0;
356 let mut humidity_avg = 0.0;
357 let mut humidity_count = 0.0;
358 let mut wind_kmh_north_avg = 0.0;
359 let mut wind_kmh_east_avg = 0.0;
360 let mut wind_count = 0.0;
361 let mut max = ForecastAggregate {
362 temp: f64::MIN,
363 apparent: f64::MIN,
364 humidity: f64::MIN,
365 wind_kmh: f64::MIN,
366 wind_direction: None,
367 };
368 let mut min = ForecastAggregate {
369 temp: f64::MAX,
370 apparent: f64::MAX,
371 humidity: f64::MAX,
372 wind_kmh: f64::MAX,
373 wind_direction: None,
374 };
375 for val in data {
376 if let Some(temp) = val.temp {
377 temp_avg += temp;
378 max.temp = max.temp.max(temp);
379 min.temp = min.temp.min(temp);
380 temp_count += 1.0;
381 }
382 if let Some(apparent) = val.apparent {
383 apparent_avg += apparent;
384 max.apparent = max.apparent.max(apparent);
385 min.apparent = min.apparent.min(apparent);
386 apparent_count += 1.0;
387 }
388 if let Some(humidity) = val.humidity {
389 humidity_avg += humidity;
390 max.humidity = max.humidity.max(humidity);
391 min.humidity = min.humidity.min(humidity);
392 humidity_count += 1.0;
393 }
394
395 if let Some(wind_kmh) = val.wind_kmh {
396 if let Some(degrees) = val.wind_direction {
397 let (sin, cos) = degrees.to_radians().sin_cos();
398 wind_kmh_north_avg += wind_kmh * cos;
399 wind_kmh_east_avg += wind_kmh * sin;
400 wind_count += 1.0;
401 }
402
403 if wind_kmh > max.wind_kmh {
404 max.wind_direction = val.wind_direction;
405 max.wind_kmh = wind_kmh;
406 }
407
408 if wind_kmh < min.wind_kmh {
409 min.wind_direction = val.wind_direction;
410 min.wind_kmh = wind_kmh;
411 }
412 }
413 }
414
415 temp_avg /= temp_count;
416 humidity_avg /= humidity_count;
417 apparent_avg /= apparent_count;
418
419 let (wind_kmh_avg, wind_direction_avg) = if wind_count == 0.0 {
421 (0.0, None)
422 } else {
423 (
424 wind_kmh_east_avg.hypot(wind_kmh_north_avg) / wind_count,
425 Some(
426 wind_kmh_east_avg
427 .atan2(wind_kmh_north_avg)
428 .to_degrees()
429 .rem_euclid(360.0),
430 ),
431 )
432 };
433
434 let avg = ForecastAggregate {
435 temp: temp_avg,
436 apparent: apparent_avg,
437 humidity: humidity_avg,
438 wind_kmh: wind_kmh_avg,
439 wind_direction: wind_direction_avg,
440 };
441 Self { avg, min, max, fin }
442 }
443}
444
445pub(crate) fn prepare(config: &Config) -> Result<Arc<BlockPlan>> {
446 let weather_icons = || IconChoices::fixed(WeatherIcon::ALL_NAMES);
447 let declare = |output: OutputPlan| {
448 output
449 .icon("icon", weather_icons())
450 .icon("icon_ffin", weather_icons())
451 };
452 let formats = config.formats.with_default(" $icon $weather $temp ")?;
453 BlockPlan::new(format_outputs(formats, declare))
454}
455
456pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
457 let mut actions = api.get_actions()?;
458 api.set_default_actions(&[
459 (MouseButton::Left, None, "next_format"),
460 (MouseButton::Right, None, "prev_format"),
461 ])?;
462
463 let mut formats = FormatRotation::new(plan)?;
464
465 let (provider, service_units): (Box<dyn WeatherProvider + Send + Sync>, UnitSystem) =
466 match &config.service {
467 WeatherService::MetNo(service_config) => (
468 Box::new(met_no::Service::new(service_config)?),
469 UnitSystem::default(),
470 ),
471 WeatherService::OpenWeatherMap(service_config) => (
472 Box::new(open_weather_map::Service::new(config.autolocate, service_config).await?),
473 service_config.units,
474 ),
475 WeatherService::Nws(service_config) => (
476 Box::new(nws::Service::new(config.autolocate, service_config).await?),
477 service_config.units,
478 ),
479 };
480 let units = config.units.unwrap_or(service_units);
481
482 let autolocate_interval = config.autolocate_interval.unwrap_or(config.interval).0;
483 let declared_formats: Vec<Format> = plan.outputs().map(|o| o.format().clone()).collect();
486 let need_forecast = need_forecast(&declared_formats);
487
488 let mut timer = config.interval.timer();
489
490 loop {
491 let location = if config.autolocate {
492 Some(
493 api.find_ip_location(&REQWEST_CLIENT, autolocate_interval)
494 .await?,
495 )
496 } else {
497 None
498 };
499
500 let fetch = || provider.get_weather(location.as_ref(), need_forecast);
501 let data = fetch.retry(ExponentialBuilder::default()).await?;
502 let data_values = data.into_values(&units);
505
506 loop {
507 let output = formats.current();
508 let mut widget = output.new_widget();
509 widget.set_values(data_values.clone());
510 api.set_widget(widget)?;
511
512 select! {
513 _ = timer.tick() => break,
514 _ = api.wait_for_update_request() => break,
515 Some(action) = actions.recv() => match action.as_ref() {
516 "next_format" | "toggle_format" => {
517 formats.next();
518 }
519 "prev_format" => {
520 formats.prev();
521 }
522 _ => (),
523 }
524 }
525 }
526 }
527}
528
529fn need_forecast(formats: &[Format]) -> bool {
530 fn has_forecast_key(format: &Format) -> bool {
531 macro_rules! format_suffix {
532 ($($suffix: literal),* $(,)?) => {
533 false
534 $(
535 || format.contains_key(concat!("temp_f", $suffix))
536 || format.contains_key(concat!("apparent_f", $suffix))
537 || format.contains_key(concat!("humidity_f", $suffix))
538 || format.contains_key(concat!("wind_f", $suffix))
539 || format.contains_key(concat!("wind_kmh_f", $suffix))
540 || format.contains_key(concat!("direction_f", $suffix))
541 )*
542 };
543 }
544
545 format_suffix!("avg", "min", "max", "fin")
546 || format.contains_key("icon_ffin")
547 || format.contains_key("weather_ffin")
548 || format.contains_key("weather_verbose_ffin")
549 }
550 formats.iter().any(has_forecast_key)
551}
552
553fn calculate_sunrise_sunset(
554 lat: f64,
555 lon: f64,
556 altitude: Option<f64>,
557) -> Result<(Option<DateTime<Utc>>, Option<DateTime<Utc>>)> {
558 let date = Utc::now().date_naive();
559 let coordinates = sunrise::Coordinates::new(lat, lon).error("Invalid coordinates")?;
560 let solar_day = SolarDay::new(coordinates, date).with_altitude(altitude.unwrap_or_default());
561
562 Ok((
563 solar_day.event_time(SolarEvent::Sunrise),
564 solar_day.event_time(SolarEvent::Sunset),
565 ))
566}
567
568#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, SmartDefault)]
569#[serde(rename_all = "lowercase")]
570pub enum UnitSystem {
571 #[default]
572 Metric,
573 Imperial,
574}
575
576impl AsRef<str> for UnitSystem {
577 fn as_ref(&self) -> &str {
578 match self {
579 UnitSystem::Metric => "metric",
580 UnitSystem::Imperial => "imperial",
581 }
582 }
583}
584
585impl UnitSystem {
586 fn temperature_value(&self, temp_celsius: f64) -> Value {
587 match self {
588 UnitSystem::Metric => Value::degrees_c(temp_celsius),
589 UnitSystem::Imperial => Value::degrees_f(celsius_to_fahrenheit(temp_celsius)),
590 }
591 }
592
593 fn wind_speed_value(&self, speed_kmh: f64) -> Value {
594 match self {
595 UnitSystem::Metric => Value::number(kmh_to_mps(speed_kmh)),
596 UnitSystem::Imperial => Value::number(kmh_to_mph(speed_kmh)),
597 }
598 }
599}
600
601fn convert_wind_direction(direction_opt: Option<f64>) -> &'static str {
603 match direction_opt {
604 Some(direction) => match direction.round() as i64 {
605 24..=68 => "NE",
606 69..=113 => "E",
607 114..=158 => "SE",
608 159..=203 => "S",
609 204..=248 => "SW",
610 249..=293 => "W",
611 294..=338 => "NW",
612 _ => "N",
613 },
614 None => "-",
615 }
616}
617
618fn australian_apparent_temp(temp: f64, humidity: f64, wind_speed: f64) -> f64 {
620 let exponent = 17.27 * temp / (237.7 + temp);
621 let water_vapor_pressure = humidity * 0.06105 * exponent.exp();
622 temp + 0.33 * water_vapor_pressure - 0.7 * wind_speed - 4.0
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628
629 #[test]
630 fn test_new_forecast_average_wind_speed() {
631 let mut degrees = 0.0;
632 while degrees < 360.0 {
633 let forecast = Forecast::new(
634 &[
635 ForecastAggregateSegment {
636 temp: None,
637 apparent: None,
638 humidity: None,
639 wind_kmh: Some(3.6),
640 wind_direction: Some(degrees),
641 },
642 ForecastAggregateSegment {
643 temp: None,
644 apparent: None,
645 humidity: None,
646 wind_kmh: Some(7.2),
647 wind_direction: Some(degrees),
648 },
649 ],
650 WeatherMoment::default(),
651 );
652 assert!((forecast.avg.wind_kmh - 5.4).abs() < 0.1);
653 assert!((forecast.avg.wind_direction.unwrap() - degrees).abs() < 0.1);
654
655 degrees += 15.0;
656 }
657 }
658
659 #[test]
660 fn test_new_forecast_average_wind_degrees() {
661 let mut degrees = 0.0;
662 while degrees < 360.0 {
663 let low = degrees - 1.0;
664 let high = degrees + 1.0;
665 let forecast = Forecast::new(
666 &[
667 ForecastAggregateSegment {
668 temp: None,
669 apparent: None,
670 humidity: None,
671 wind_kmh: Some(3.6),
672 wind_direction: Some(low),
673 },
674 ForecastAggregateSegment {
675 temp: None,
676 apparent: None,
677 humidity: None,
678 wind_kmh: Some(3.6),
679 wind_direction: Some(high),
680 },
681 ],
682 WeatherMoment::default(),
683 );
684 assert!((forecast.avg.wind_direction.unwrap() - degrees).abs() < 0.1);
687
688 degrees += 15.0;
689 }
690 }
691
692 #[test]
693 fn test_new_forecast_average_wind_speed_and_degrees() {
694 let mut degrees = 0.0;
695 while degrees < 360.0 {
696 let low = degrees - 1.0;
697 let high = degrees + 1.0;
698 let forecast = Forecast::new(
699 &[
700 ForecastAggregateSegment {
701 temp: None,
702 apparent: None,
703 humidity: None,
704 wind_kmh: Some(3.6),
705 wind_direction: Some(low),
706 },
707 ForecastAggregateSegment {
708 temp: None,
709 apparent: None,
710 humidity: None,
711 wind_kmh: Some(7.2),
712 wind_direction: Some(high),
713 },
714 ],
715 WeatherMoment::default(),
716 );
717 assert!((low + high) / 2.0 < forecast.avg.wind_direction.unwrap());
722 assert!(forecast.avg.wind_direction.unwrap() < high);
723 degrees += 15.0;
724 }
725 }
726
727 fn config(toml_str: &str) -> Config {
728 toml::from_str(toml_str).unwrap()
729 }
730
731 #[test]
732 fn every_condition_icon_is_declared() {
733 let all_variants = [
734 WeatherIcon::Clear { is_night: false },
735 WeatherIcon::Clear { is_night: true },
736 WeatherIcon::Clouds { is_night: false },
737 WeatherIcon::Clouds { is_night: true },
738 WeatherIcon::Fog { is_night: false },
739 WeatherIcon::Fog { is_night: true },
740 WeatherIcon::Rain { is_night: false },
741 WeatherIcon::Rain { is_night: true },
742 WeatherIcon::Snow,
743 WeatherIcon::Thunder { is_night: false },
744 WeatherIcon::Thunder { is_night: true },
745 WeatherIcon::Default,
746 ];
747 assert_eq!(all_variants.len(), WeatherIcon::ALL_NAMES.len());
748 for variant in all_variants {
749 assert!(WeatherIcon::ALL_NAMES.contains(&variant.to_icon_str()));
750 }
751 }
752
753 #[test]
754 fn every_configured_format_gets_an_output() {
755 let plan = prepare(&config("service = { name = \"metno\" }")).unwrap();
756 assert!(plan.output("format").is_ok());
757 assert!(plan.output("format2").is_err());
758
759 let plan = prepare(&config(
760 "service = { name = \"metno\" }\nformat = [\" $icon $temp \", \" $humidity \"]",
761 ))
762 .unwrap();
763 let ids: Vec<_> = plan.outputs().map(|o| o.id()).collect();
764 assert_eq!(ids, ["format", "format2"]);
765 let alt = plan.output("format2").unwrap();
766 assert!(alt.format().contains_key("humidity"));
767 let choices = alt.output().choices_for("icon").unwrap();
768 assert!(choices.permits("weather_snow"));
769 assert!(!choices.permits("bat"));
770 }
771
772 #[test]
773 fn format_alt_still_produces_a_second_output() {
774 let plan = prepare(&config(
775 "service = { name = \"metno\" }\nformat_alt = \" $humidity \"",
776 ))
777 .unwrap();
778 let alt = plan.output("format2").unwrap();
779 assert!(alt.format().contains_key("humidity"));
780 assert!(
781 alt.output()
782 .choices_for("icon_ffin")
783 .unwrap()
784 .permits("weather_default")
785 );
786 }
787
788 #[test]
789 fn forecast_is_needed_when_any_rotated_format_asks_for_it() {
790 let plan = prepare(&config(
791 "service = { name = \"metno\" }\nformat = [\" $icon $temp \", \" $temp_favg \"]",
792 ))
793 .unwrap();
794 let formats: Vec<Format> = plan.outputs().map(|o| o.format().clone()).collect();
795 assert!(need_forecast(&formats));
796
797 let plan = prepare(&config(
798 "service = { name = \"metno\" }\nformat = [\" $icon $temp \", \" $humidity \"]",
799 ))
800 .unwrap();
801 let formats: Vec<Format> = plan.outputs().map(|o| o.format().clone()).collect();
802 assert!(!need_forecast(&formats));
803 }
804}