Add weather data to cockpit
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
Isaac Mills 2025-07-28 17:43:41 -06:00
parent 265f8d8dc3
commit 450183c06b
Signed by: fnmain
GPG key ID: B67D7410F33A0F61
5 changed files with 419 additions and 70 deletions

View file

@ -43,9 +43,9 @@ dotenvy_macro = "0.15.7"
futures-util = "0.3.31" futures-util = "0.3.31"
getrandom = { version = "0.3.3", features = ["wasm_js"] } getrandom = { version = "0.3.3", features = ["wasm_js"] }
heapless = "0.8.0" heapless = "0.8.0"
openweathermap_lib = { git = "https://github.com/StratusFearMe21/rusty-openweathermap-library", branch = "wasm_deps" }
phf = { version = "0.12.1", features = ["macros"] }
rand = { version = "0.9.2", default-features = false, features = ["os_rng", "std"] } rand = { version = "0.9.2", default-features = false, features = ["os_rng", "std"] }
reqwest = { version = "0.12.22", features = ["json"] }
serde = { version = "1.0.219", features = ["derive"] }
tachyonfx = { version = "0.16.0", default-features = false, features = ["web-time"] } tachyonfx = { version = "0.16.0", default-features = false, features = ["web-time"] }
web-time = "1.1.0" web-time = "1.1.0"

View file

@ -1,30 +1,35 @@
use std::{ use std::{
cell::OnceCell,
future::Future, future::Future,
rc::Rc, rc::Rc,
sync::{ sync::atomic::{AtomicUsize, Ordering},
atomic::{AtomicUsize, Ordering},
mpsc::{self, Receiver, Sender},
},
}; };
use openweathermap_lib::{
location::Location,
weather::{WeatherClient, WeatherResponse},
};
use rand::{rngs::OsRng, seq::IndexedRandom, TryRngCore}; use rand::{rngs::OsRng, seq::IndexedRandom, TryRngCore};
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use ratzilla::ratatui; use ratzilla::ratatui;
use dotenvy_macro::dotenv; use dotenvy_macro::dotenv;
use ratatui::{ use ratatui::{
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Margin, Rect},
style::{Color, Style, Stylize}, style::{Color, Style, Stylize},
widgets::{Block, Borders, Paragraph, Tabs}, widgets::{Block, Borders, Paragraph, Tabs},
Frame, Frame,
}; };
use serde::Deserialize;
use tachyonfx::{fx, Effect, Interpolation, Shader}; use tachyonfx::{fx, Effect, Interpolation, Shader};
use web_time::Instant; use web_time::Instant;
use crate::app::password_locker::PasswordLocker; use crate::app::{
password_locker::PasswordLocker,
weather_icon::{WeatherIcon, DAY_ICONS},
};
mod password_locker; mod password_locker;
mod weather_icon;
#[derive(Default, Debug, Clone)] #[derive(Default, Debug, Clone)]
struct SelectedTab { struct SelectedTab {
@ -68,32 +73,11 @@ impl Shader for SelectedTab {
} }
pub trait AppExecutor { pub trait AppExecutor {
fn execute<T: 'static>(&self, future: impl Future<Output = T> + 'static, sender: Sender<T>); fn execute<T: 'static>(
} &self,
future: impl Future<Output = T> + 'static,
pub struct Pending<T> { output_cell: Rc<OnceCell<T>>,
rx: Receiver<T>, );
resolved: Option<T>,
}
impl<T> Pending<T> {
pub fn new(rx: Receiver<T>) -> Self {
Self { rx, resolved: None }
}
pub fn resolved(&mut self) -> &Option<T> {
if self.resolved.is_some() {
return &self.resolved;
}
self.resolved = self.rx.try_recv().ok();
&self.resolved
}
}
#[derive(Deserialize, Debug)]
pub struct WeatherInfo {
name: String,
} }
pub struct App<E: AppExecutor> { pub struct App<E: AppExecutor> {
@ -102,7 +86,7 @@ pub struct App<E: AppExecutor> {
selected_tab: SelectedTab, selected_tab: SelectedTab,
password_locker: PasswordLocker, password_locker: PasswordLocker,
current_effect: Effect, current_effect: Effect,
weather: Pending<WeatherInfo>, weather: Rc<OnceCell<WeatherResponse>>,
executor: E, executor: E,
} }
@ -148,24 +132,31 @@ const TABS: [&'static str; 10] = [
impl<E: AppExecutor> App<E> { impl<E: AppExecutor> App<E> {
pub fn new(executor: E) -> Self { pub fn new(executor: E) -> Self {
let (weather_tx, weather_rx) = mpsc::channel(); let weather = Rc::new(OnceCell::new());
executor.execute(async move { executor.execute(
let weather_info: WeatherInfo = reqwest::get(concat!("https://api.openweathermap.org/data/2.5/weather?lat=40.7660851712019&lon=-111.89066476757807&appid=", dotenv!("OPENWEATHERMAP_API_KEY"))) async move {
.await let weather_client = WeatherClient::new(
.unwrap() Location {
.json() zip: String::from("84111"),
.await name: String::from("Salt Lake City"),
.unwrap(); lat: 40.7660851712019,
weather_info lon: -111.89066476757807,
}, country: String::from("US"),
weather_tx); },
"metric".to_owned(),
dotenv!("OPENWEATHERMAP_API_KEY").to_owned(),
);
weather_client.get_current_weather().await.unwrap()
},
Rc::clone(&weather),
);
Self { Self {
tabs: TABS.choose_multiple_array(&mut OsRng.unwrap_err()).unwrap(), tabs: TABS.choose_multiple_array(&mut OsRng.unwrap_err()).unwrap(),
transition_instant: Instant::now(), transition_instant: Instant::now(),
selected_tab: SelectedTab::default(), selected_tab: SelectedTab::default(),
password_locker: PasswordLocker::default(), password_locker: PasswordLocker::default(),
current_effect: fx::fade_from_fg(Color::Black, (0, Interpolation::CircIn)), current_effect: fx::fade_from_fg(Color::Black, (0, Interpolation::CircIn)),
weather: Pending::new(weather_rx), weather,
executor, executor,
} }
} }
@ -211,26 +202,85 @@ impl<E: AppExecutor> App<E> {
); );
} }
pub fn draw_first_tab(&mut self, frame: &mut Frame, layout: Rect) { pub fn draw_first_tab(&mut self, frame: &mut Frame, layout_outer: Rect) {
let layout = Layout::new( let layout = Layout::new(
Direction::Horizontal, if layout_outer.width < (layout_outer.height * 2) {
Direction::Vertical
} else {
Direction::Horizontal
},
[Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)], [Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)],
) )
.split(layout); .split(layout_outer);
frame.render_widget( if let Some(weather) = self.weather.get() {
Paragraph::new( frame.render_widget(
self.weather Block::new()
.resolved() .title("Weather")
.as_ref() .borders(Borders::all())
.map(|weather| weather.name.as_str()) .fg(Color::Rgb(226, 190, 89))
.unwrap_or("Loading"), .bg(Color::Black),
layout[0],
);
let layout = Layout::new(
Direction::Vertical,
[Constraint::Length(12), Constraint::Min(0)],
) )
.alignment(ratatui::layout::Alignment::Center) .split(layout[0].inner(Margin::new(2, 2)));
.fg(Color::Rgb(226, 190, 89))
.bg(Color::Black) let layout_upper = Layout::new(
.block(Block::new().title("Weather").borders(Borders::all())), Direction::Horizontal,
layout[0], [Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)],
); )
.split(layout[0]);
frame.render_widget(
WeatherIcon(DAY_ICONS.get(&weather.weather[0].icon).unwrap()),
layout_upper[0],
);
frame.render_widget(
Paragraph::new(format!(
"{} {}°C\n~{}°C -{}°C +{}°C\n{} {} {}m/s SSE\nVisibility: {}km",
weather.name,
weather.main.temp.unwrap_or(f64::NAN),
weather.main.feels_like.unwrap_or(f64::NAN),
weather.main.temp_min.unwrap_or(f64::NAN),
weather.main.temp_max.unwrap_or(f64::NAN),
weather.weather[0].main,
match weather.wind.deg {
0..=45 => "🡓",
46..=90 => "🡗",
91..=135 => "🡐",
136..=180 => "🡔",
181..=225 => "🡑",
226..=270 => "🡕",
271..=315 => "🡒",
_ => "🡖",
},
weather.wind.speed,
weather.visibility as f64 / 1000.0,
))
.fg(Color::Rgb(226, 190, 89))
.bg(Color::Black),
layout_upper[1],
);
frame.render_widget(
Block::new()
.borders(Borders::all())
.fg(Color::Rgb(226, 190, 89))
.bg(Color::Black),
layout[1],
);
} else {
frame.render_widget(
Paragraph::new("Loading")
.alignment(ratatui::layout::Alignment::Center)
.fg(Color::Rgb(226, 190, 89))
.bg(Color::Black)
.block(Block::new().title("Weather").borders(Borders::all())),
layout[0],
);
}
frame.render_widget( frame.render_widget(
Paragraph::new(SPLASH) Paragraph::new(SPLASH)
.alignment(ratatui::layout::Alignment::Center) .alignment(ratatui::layout::Alignment::Center)

View file

@ -0,0 +1,293 @@
use phf::phf_map;
use ratatui::{style::Color, widgets::Widget};
pub const DAY_ICONS: phf::Map<&'static str, [&'static str; 3]> = phf_map! {
"01d" => [
"",
"",
r#"
+==========
================
+===================
+=====================
+=======================
=========================
=========================
========================+
========================
=======================
=====================
+=================
============+
"#,
],
"02d" => [
"",
r#"
......
..........
............
.................
......................
........................
.............................
..............................
..............................
.............................
.........................
"#,
r#"
==========
==============
=+=============
-=============+
:+========
+=======
+++====
+=
"#,
],
"03d" => [
"",
r#"
.........
............
..................
....................
........................
............................
..............................
..............................
.............................
........................
"#,
"",
],
"04d" => [
r#"
########
%#########
:#############
#############
=:..:##########
=##########
-%#########
+#####
%
"#,
r#"
:...
.........
............
............
:....................
.......................
............................
..............................
..............................
............................
........................
"#,
"",
],
"09d" => [
r#"
#######
-%#########
+##########
-..=########
:%#######
:-######
-###
:=
## %
## ## ##
### ###
### ##
"#,
r#"
.....
.........
..........
.................
....................
.......................
........................
......:=..............
"#,
"",
],
"10d" => [
r#"
## %
## ## ##
### *##
### ##
"#,
r#"
......
.........
..........:..
..................
.....................
........................
........................
......:+..............
"#,
r#"
=========+
+===========
-+==========
:+======
+=====
:=+=+
"#,
],
"11d" => [
r#"
######
#########
%###########
+############
*-:-##########
-##########
:%%########
+#####
"#,
r#"
.......
..........
............
..................
......................
...........................
.............................
......... ...............
....... ................
"#,
r#"
+=+:
+=+
+====+
===
+=+
+=
"#,
],
"13d" => [
r#"
##
# ######## #
##### ###### ### #
###### ## ######
####################
# %## ### #
####################
##### ## #####
##### #### #####
##############
## ## ##
"#,
"",
"",
],
"50d" => [
r#"
##########
#################
####################
%##################
#####################
#####################
##############
"#,
"",
"",
],
};
pub struct WeatherIcon(pub &'static [&'static str; 3]);
impl Widget for WeatherIcon {
fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer)
where
Self: Sized,
{
for (layer, color) in [
(0, Color::Rgb(72, 72, 72)),
(1, Color::Rgb(242, 242, 242)),
(2, Color::Rgb(236, 110, 76)),
] {
for (y, line) in self.0[layer].lines().skip(1).enumerate() {
for (x, byte) in line.bytes().enumerate() {
if byte != b' ' {
let cell = buf
.cell_mut((area.x + x as u16, area.y + y as u16))
.unwrap();
cell.fg = color;
cell.set_symbol(unsafe { std::str::from_utf8_unchecked(&[byte]) });
}
}
}
}
}
}

View file

@ -1,4 +1,4 @@
use std::{future::Future, time::Duration}; use std::{cell::OnceCell, future::Future, rc::Rc, time::Duration};
use futures_util::FutureExt; use futures_util::FutureExt;
use ratatui::crossterm::event; use ratatui::crossterm::event;
@ -10,9 +10,9 @@ impl AppExecutor for TokioExecutor {
fn execute<T: 'static>( fn execute<T: 'static>(
&self, &self,
future: impl Future<Output = T> + 'static, future: impl Future<Output = T> + 'static,
sender: std::sync::mpsc::Sender<T>, output_cell: Rc<OnceCell<T>>,
) { ) {
tokio::task::spawn_local(future.map(move |output| sender.send(output).unwrap())); tokio::task::spawn_local(future.map(move |output| output_cell.set(output)));
} }
} }

View file

@ -1,4 +1,8 @@
use std::{cell::RefCell, future::Future, rc::Rc}; use std::{
cell::{OnceCell, RefCell},
future::Future,
rc::Rc,
};
use futures_util::FutureExt; use futures_util::FutureExt;
use ratzilla::{ use ratzilla::{
@ -22,9 +26,11 @@ impl AppExecutor for WasmBindgenExecutor {
fn execute<T: 'static>( fn execute<T: 'static>(
&self, &self,
future: impl Future<Output = T> + 'static, future: impl Future<Output = T> + 'static,
sender: std::sync::mpsc::Sender<T>, output_cell: Rc<OnceCell<T>>,
) { ) {
wasm_bindgen_futures::spawn_local(future.map(move |output| sender.send(output).unwrap())); wasm_bindgen_futures::spawn_local(future.map(move |output| {
let _ = output_cell.set(output);
}));
} }
} }