New Github notifications thingy

This commit is contained in:
FliegendeWurst 2024-11-08 12:48:27 +01:00
parent ad9cea9378
commit efac4f49a7
10 changed files with 972 additions and 454 deletions

1010
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@ embedded-graphics = "0.8.1"
linux-embedded-hal = "0.3.0" linux-embedded-hal = "0.3.0"
embedded-hal = "0.2.5" embedded-hal = "0.2.5"
libc = "0.2.98" libc = "0.2.98"
rusqlite = "0.27.0" rusqlite = "0.32.1"
time = { version = "0.3.9", features = ["parsing"] } time = { version = "0.3.9", features = ["parsing"] }
time-tz = "2" time-tz = "2"
image = { version = "0.24.1", optional = true } image = { version = "0.24.1", optional = true }
@ -21,7 +21,7 @@ serde = "1.0.136"
rppal = { version = "0.14.1", features = ["hal"] } rppal = { version = "0.14.1", features = ["hal"] }
ssd1351 = { git = "https://github.com/FliegendeWurst/ssd1351-rust", rev = "3de5be50bd9a59391c669aec8357923a56d121f6" } ssd1351 = { git = "https://github.com/FliegendeWurst/ssd1351-rust", rev = "3de5be50bd9a59391c669aec8357923a56d121f6" }
display-interface-spi = "0.4.1" display-interface-spi = "0.4.1"
ureq = { version = "2.4.0", default-features = false } ureq = { version = "2.4.0", default-features = false, features = ["tls"] }
winit = { version = "0.28.7", optional = true } winit = { version = "0.28.7", optional = true }
softbuffer = { version = "0.3.1", optional = true } softbuffer = { version = "0.3.1", optional = true }
rand_xoshiro = "0.6.0" rand_xoshiro = "0.6.0"
@ -29,6 +29,7 @@ gpiocdev = "0.6.0"
rpassword = "7.2.0" rpassword = "7.2.0"
andotp-import = "0.1.0" andotp-import = "0.1.0"
totp-rs = "5.4.0" totp-rs = "5.4.0"
color_space = "0.5.4"
#gpio-am2302-rs = { git = "https://github.com/FliegendeWurst/gpio-am2302-rs" } #gpio-am2302-rs = { git = "https://github.com/FliegendeWurst/gpio-am2302-rs" }
[features] [features]

View File

@ -321,8 +321,8 @@ impl<D: DrawTarget<Color = Rgb565>> Draw<D> for Measurements {
} }
if time_until_first.is_none() if time_until_first.is_none()
&& (i > 0 && (i > 0
|| event.1 > time.hour() as i32 || (event.1 == time.hour() as i32 || event.1 > time.hour() as i32
&& event.2 >= time.minute() as i32)) || (event.1 == time.hour() as i32 && event.2 >= time.minute() as i32))
{ {
time_until_first = Some( time_until_first = Some(
((i * 24 + event.1) * 60 + event.2) * 60 ((i * 24 + event.1) * 60 + event.2) * 60

View File

@ -22,7 +22,7 @@ use rppal::{
spi::{Bus, Mode, SlaveSelect, Spi}, spi::{Bus, Mode, SlaveSelect, Spi},
}; };
use rusqlite::Connection; use rusqlite::Connection;
use schedule::Schedule; use schedule::{github_notifications::GithubNotifications, Schedule};
use screensaver::{Screensaver, TimeDisplay}; use screensaver::{Screensaver, TimeDisplay};
use ssd1351::display::display::Ssd1351; use ssd1351::display::display::Ssd1351;
use time::OffsetDateTime; use time::OffsetDateTime;
@ -37,6 +37,8 @@ pub type Oled = Ssd1351<SPIInterfaceNoCS<Spi, OutputPin>>;
pub type Rng = Xoroshiro128StarStar; pub type Rng = Xoroshiro128StarStar;
static BLACK: Rgb565 = Rgb565::new(0, 0, 0); static BLACK: Rgb565 = Rgb565::new(0, 0, 0);
/// Delay after drawing a frame in milliseconds.
const FRAME_INTERVAL: u64 = 66;
fn main() { fn main() {
if rppal::system::DeviceInfo::new().is_ok() { if rppal::system::DeviceInfo::new().is_ok() {
@ -46,7 +48,9 @@ fn main() {
} }
} }
pub trait Context { pub trait Context<D: DrawTarget<Color = Rgb565>> {
fn do_draw(&self, drawable: Box<dyn Draw<D>>);
fn do_action(&self, action: Action); fn do_action(&self, action: Action);
fn active_count(&self) -> usize; fn active_count(&self) -> usize;
@ -58,7 +62,7 @@ pub trait Context {
pub struct ContextDefault<D: DrawTarget<Color = Rgb565>> { pub struct ContextDefault<D: DrawTarget<Color = Rgb565>> {
screensavers: Vec<Box<dyn Screensaver<D>>>, screensavers: Vec<Box<dyn Screensaver<D>>>,
scheduled: Vec<Box<dyn Schedule>>, scheduled: Vec<Box<dyn Schedule<D>>>,
active: RefCell<Vec<Box<dyn Draw<D>>>>, active: RefCell<Vec<Box<dyn Draw<D>>>>,
database: Rc<RefCell<Connection>>, database: Rc<RefCell<Connection>>,
} }
@ -70,10 +74,16 @@ impl<D: DrawTarget<Color = Rgb565>> ContextDefault<D> {
screensavers.push(Box::new(draw::Measurements::temps())); screensavers.push(Box::new(draw::Measurements::temps()));
screensavers.push(Box::new(draw::Measurements::events())); screensavers.push(Box::new(draw::Measurements::events()));
let database = Connection::open("sensors.db").expect("failed to open database"); let database = Connection::open("sensors.db").expect("failed to open database");
let mut scheduled = schedule::reminders();
scheduled.push(Box::new(GithubNotifications {
pat: env::var("GITHUB_PAT").expect("no env var GITHUB_PAT set"),
last_modified: RefCell::new(None),
last_call: RefCell::new(OffsetDateTime::now_utc().to_timezone(BERLIN) - time::Duration::seconds(50)),
}));
ContextDefault { ContextDefault {
database: Rc::new(RefCell::new(database)), database: Rc::new(RefCell::new(database)),
screensavers, screensavers,
scheduled: schedule::reminders(), scheduled,
active: RefCell::new(vec![Box::new(TimeDisplay::new())]), active: RefCell::new(vec![Box::new(TimeDisplay::new())]),
} }
} }
@ -112,7 +122,11 @@ impl<D: DrawTarget<Color = Rgb565>> ContextDefault<D> {
} }
} }
impl<D: DrawTarget<Color = Rgb565>> Context for ContextDefault<D> { impl<D: DrawTarget<Color = Rgb565>> Context<D> for ContextDefault<D> {
fn do_draw(&self, drawable: Box<dyn Draw<D>>) {
self.active.borrow_mut().push(drawable);
}
fn do_action(&self, action: Action) { fn do_action(&self, action: Action) {
match action { match action {
Action::Screensaver(id) => { Action::Screensaver(id) => {
@ -226,7 +240,7 @@ fn pc_main() {
.unwrap(); .unwrap();
// redraw // redraw
if Instant::now().duration_since(start) > Duration::from_millis(iters * 66) { if Instant::now().duration_since(start) > Duration::from_millis(iters * FRAME_INTERVAL) {
iters += 1; iters += 1;
buffer_dirty = ctx.loop_iter(&mut disp, &mut rng); buffer_dirty = ctx.loop_iter(&mut disp, &mut rng);
} }
@ -428,6 +442,6 @@ fn main_loop(mut disp: Oled, mut ctx: ContextDefault<Oled>) {
if dirty { if dirty {
let _ = disp.flush(); // ignore bus write errors, they are harmless let _ = disp.flush(); // ignore bus write errors, they are harmless
} }
thread::sleep(Duration::from_millis(66)); thread::sleep(Duration::from_millis(FRAME_INTERVAL));
} }
} }

View File

@ -0,0 +1,117 @@
use std::cell::RefCell;
use color_space::{Hsv, ToRgb};
use embedded_graphics::{
mono_font::{iso_8859_10::FONT_8X13, MonoTextStyleBuilder},
pixelcolor::Rgb565,
prelude::{DrawTarget, Point, RgbColor},
text::Text,
Drawable,
};
use raspi_oled::github::get_new_notifications;
use crate::{
screensaver::{SimpleScreensaver, GITHUB},
Draw,
};
use super::Schedule;
pub struct GithubNotifications {
pub pat: String,
pub last_modified: RefCell<Option<String>>,
pub last_call: RefCell<time::OffsetDateTime>,
}
impl<D: DrawTarget<Color = Rgb565>> Schedule<D> for GithubNotifications {
fn check(&self, _ctx: &dyn crate::Context<D>, time: time::OffsetDateTime) -> bool {
let time_since_last = time - *self.last_call.borrow();
time_since_last.whole_minutes() >= 1
}
fn execute(&self, ctx: &dyn crate::Context<D>, time: time::OffsetDateTime) {
*self.last_call.borrow_mut() = time;
let last_modified = self.last_modified.borrow().clone();
if let Ok((notifications, last_modified)) = get_new_notifications(&self.pat, last_modified.as_deref()) {
*self.last_modified.borrow_mut() = last_modified;
let relevant: Vec<_> = notifications
.into_iter()
.filter(|x| x.reason != "state_change" && x.unread)
.collect();
if relevant.is_empty() {
return;
}
let mut lines = vec![];
let mut relevant = relevant.into_iter();
for _ in 0..8 {
if let Some(x) = relevant.next() {
let url = x.subject.url;
let Some(url) = url else {
lines.push("no url".to_owned());
continue;
};
let parts: Vec<_> = url.split('/').collect();
if parts.len() < 8 {
lines.push("too few url parts".to_owned());
continue;
}
lines.push(format!("{} #{}", parts[5], parts[7]));
}
}
let remaining = relevant.count();
if remaining != 0 {
lines.push(format!("... {} more", remaining));
}
ctx.do_draw(Box::new(GithubNotificationsDraw {
calls: RefCell::new(0),
screen: &GITHUB,
lines,
}));
}
}
}
struct GithubNotificationsDraw {
calls: RefCell<usize>,
screen: &'static SimpleScreensaver,
lines: Vec<String>,
}
impl<D: DrawTarget<Color = Rgb565>> Draw<D> for GithubNotificationsDraw {
fn draw(&self, disp: &mut D, _rng: &mut crate::Rng) -> Result<bool, D::Error> {
let calls = *self.calls.borrow();
if calls < 40 {
let hue = calls as f64 / 40.0 * 360.0;
let hsv = Hsv::new(hue, 1.0, 1.0);
let rgb = hsv.to_rgb();
let r = rgb.r as u8 >> 3;
let g = rgb.g as u8 >> 2;
let b = rgb.b as u8 >> 3;
self.screen.draw_all(disp, Rgb565::new(r, g, b))?;
} else {
disp.clear(Rgb565::BLACK)?;
// fit 9 lines
let text_style_clock = MonoTextStyleBuilder::new()
.font(&FONT_8X13)
.text_color(Rgb565::WHITE)
.build();
for (y, line) in self.lines.iter().enumerate() {
Text::new(line, Point::new(0, (12 + y * 14) as _), text_style_clock).draw(disp)?;
}
}
*self.calls.borrow_mut() += 1;
Ok(calls < 90)
}
fn expired(&self) -> bool {
*self.calls.borrow() > 90
}
fn as_any(&self) -> &dyn std::any::Any {
&*self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
&mut *self
}
}

View File

@ -1,18 +1,24 @@
use embedded_graphics::{pixelcolor::Rgb565, prelude::DrawTarget};
use time::OffsetDateTime; use time::OffsetDateTime;
use crate::{action::Action, Context}; use crate::{action::Action, Context};
pub mod github_notifications;
/// Task to be executed at certain times. /// Task to be executed at certain times.
/// Guaranteed to be checked at least once every minute. /// Guaranteed to be checked at least once every minute.
pub trait Schedule { pub trait Schedule<D>
fn check_and_do(&self, ctx: &dyn Context, time: OffsetDateTime) { where
D: DrawTarget<Color = Rgb565>,
{
fn check_and_do(&self, ctx: &dyn Context<D>, time: OffsetDateTime) {
if self.check(ctx, time) { if self.check(ctx, time) {
self.execute(ctx, time); self.execute(ctx, time);
} }
} }
fn check(&self, ctx: &dyn Context, time: OffsetDateTime) -> bool; fn check(&self, ctx: &dyn Context<D>, time: OffsetDateTime) -> bool;
fn execute(&self, ctx: &dyn Context, time: OffsetDateTime); fn execute(&self, ctx: &dyn Context<D>, time: OffsetDateTime);
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@ -23,12 +29,12 @@ pub struct Reminder {
should_beep: bool, should_beep: bool,
} }
impl Schedule for Reminder { impl<D: DrawTarget<Color = Rgb565>> Schedule<D> for Reminder {
fn check(&self, ctx: &dyn Context, time: OffsetDateTime) -> bool { fn check(&self, ctx: &dyn Context<D>, time: OffsetDateTime) -> bool {
time.hour() == self.hour && time.minute() == self.minute && ctx.active_count() == 1 time.hour() == self.hour && time.minute() == self.minute && ctx.active_count() == 1
} }
fn execute(&self, ctx: &dyn Context, _time: OffsetDateTime) { fn execute(&self, ctx: &dyn Context<D>, _time: OffsetDateTime) {
if self.should_beep { if self.should_beep {
ctx.enable_pwm(); ctx.enable_pwm();
} }
@ -51,6 +57,6 @@ static DUOLINGO: Reminder = Reminder::new(11, 40, Action::Screensaver("duolingo"
static DUOLINGO_NIGHT: Reminder = Reminder::new(23, 40, Action::Screensaver("duolingo"), false); static DUOLINGO_NIGHT: Reminder = Reminder::new(23, 40, Action::Screensaver("duolingo"), false);
static FOOD: Reminder = Reminder::new(13, 15, Action::Screensaver("plate"), false); static FOOD: Reminder = Reminder::new(13, 15, Action::Screensaver("plate"), false);
pub fn reminders() -> Vec<Box<dyn Schedule>> { pub fn reminders<D: DrawTarget<Color = Rgb565>>() -> Vec<Box<dyn Schedule<D>>> {
vec![Box::new(DUOLINGO), Box::new(DUOLINGO_NIGHT), Box::new(FOOD)] vec![Box::new(DUOLINGO), Box::new(DUOLINGO_NIGHT), Box::new(FOOD)]
} }

Binary file not shown.

View File

@ -3,6 +3,7 @@ use std::cell::RefCell;
use std::sync::atomic::{AtomicU32, AtomicU64}; use std::sync::atomic::{AtomicU32, AtomicU64};
use embedded_graphics::mono_font::ascii::FONT_10X20; use embedded_graphics::mono_font::ascii::FONT_10X20;
use embedded_graphics::prelude::RgbColor;
use embedded_graphics::{ use embedded_graphics::{
mono_font::MonoTextStyleBuilder, mono_font::MonoTextStyleBuilder,
pixelcolor::Rgb565, pixelcolor::Rgb565,
@ -102,6 +103,24 @@ impl SimpleScreensaver {
iters: AtomicU32::new(0), iters: AtomicU32::new(0),
} }
} }
pub fn draw_all<D: DrawTarget<Color = Rgb565>>(&self, disp: &mut D, color: Rgb565) -> Result<(), D::Error> {
disp.fill_contiguous(
&Rectangle::new((0, 0).into(), (128, 128).into()),
(0..128 * 128).map(|idx| {
let (red, green, blue) = (self.data[3 * idx], self.data[3 * idx + 1], self.data[3 * idx + 2]);
let r = red >> 3;
let g = green >> 2;
let b = blue >> 3;
if (r, g, b) != (0, 0, 0) {
color
} else {
Rgb565::BLACK
}
}),
)?;
Ok(())
}
} }
static TIME_COLOR: Rgb565 = Rgb565::new(0b01_111, 0b011_111, 0b01_111); static TIME_COLOR: Rgb565 = Rgb565::new(0b01_111, 0b011_111, 0b01_111);
@ -195,6 +214,7 @@ pub static RPI: SimpleScreensaver = SimpleScreensaver::new("rpi", include_bytes!
pub static DUOLINGO: SimpleScreensaver = SimpleScreensaver::new("duolingo", include_bytes!("./duolingo.raw")); pub static DUOLINGO: SimpleScreensaver = SimpleScreensaver::new("duolingo", include_bytes!("./duolingo.raw"));
pub static SPAGHETTI: SimpleScreensaver = SimpleScreensaver::new("spaghetti", include_bytes!("./spaghetti.raw")); pub static SPAGHETTI: SimpleScreensaver = SimpleScreensaver::new("spaghetti", include_bytes!("./spaghetti.raw"));
pub static PLATE: SimpleScreensaver = SimpleScreensaver::new("plate", include_bytes!("./plate.raw")); pub static PLATE: SimpleScreensaver = SimpleScreensaver::new("plate", include_bytes!("./plate.raw"));
pub static GITHUB: SimpleScreensaver = SimpleScreensaver::new("github", include_bytes!("./github.raw"));
pub fn screensavers<D: DrawTarget<Color = Rgb565>>() -> Vec<Box<dyn Screensaver<D>>> { pub fn screensavers<D: DrawTarget<Color = Rgb565>>() -> Vec<Box<dyn Screensaver<D>>> {
vec![ vec![

215
src/github.rs Normal file
View File

@ -0,0 +1,215 @@
use std::error::Error;
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct Notification {
pub id: String,
pub repository: Repository,
pub subject: Subject,
pub reason: String,
pub unread: bool,
pub updated_at: String,
pub last_read_at: Option<String>,
pub url: String,
}
#[derive(Debug, Clone, Eq, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct Repository {
pub id: u64,
pub node_id: Option<String>,
pub name: String,
pub full_name: Option<String>,
pub owner: Option<serde_json::Value>,
pub private: Option<bool>,
pub html_url: Option<String>,
pub description: Option<String>,
pub fork: Option<bool>,
pub url: String,
pub archive_url: Option<String>,
pub assignees_url: Option<String>,
pub blobs_url: Option<String>,
pub branches_url: Option<String>,
pub collaborators_url: Option<String>,
pub comments_url: Option<String>,
pub commits_url: Option<String>,
pub compare_url: Option<String>,
pub contents_url: Option<String>,
pub contributors_url: Option<String>,
pub deployments_url: Option<String>,
pub downloads_url: Option<String>,
pub events_url: Option<String>,
pub forks_url: Option<String>,
pub git_commits_url: Option<String>,
pub git_refs_url: Option<String>,
pub git_tags_url: Option<String>,
pub git_url: Option<String>,
pub issue_comment_url: Option<String>,
pub issue_events_url: Option<String>,
pub issues_url: Option<String>,
pub keys_url: Option<String>,
pub labels_url: Option<String>,
pub languages_url: Option<String>,
pub merges_url: Option<String>,
pub milestones_url: Option<String>,
pub notifications_url: Option<String>,
pub pulls_url: Option<String>,
pub releases_url: Option<String>,
pub ssh_url: Option<String>,
pub stargazers_url: Option<String>,
pub statuses_url: Option<String>,
pub subscribers_url: Option<String>,
pub subscription_url: Option<String>,
pub tags_url: Option<String>,
pub teams_url: Option<String>,
pub trees_url: Option<String>,
pub clone_url: Option<String>,
pub mirror_url: Option<String>,
pub hooks_url: Option<String>,
pub svn_url: Option<String>,
pub homepage: Option<String>,
pub language: Option<::serde_json::Value>,
pub forks_count: Option<u32>,
pub stargazers_count: Option<u32>,
pub watchers_count: Option<u32>,
pub size: Option<u32>,
pub default_branch: Option<String>,
pub open_issues_count: Option<u32>,
pub is_template: Option<bool>,
pub topics: Option<Vec<String>>,
pub has_issues: Option<bool>,
pub has_projects: Option<bool>,
pub has_wiki: Option<bool>,
pub has_pages: Option<bool>,
pub has_downloads: Option<bool>,
pub archived: Option<bool>,
pub disabled: Option<bool>,
pub visibility: Option<String>,
pub pushed_at: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub permissions: Option<serde_json::Value>,
pub allow_rebase_merge: Option<bool>,
pub template_repository: Option<Box<Repository>>,
pub allow_squash_merge: Option<bool>,
pub allow_merge_commit: Option<bool>,
pub allow_update_branch: Option<bool>,
pub allow_forking: Option<bool>,
pub subscribers_count: Option<i64>,
pub network_count: Option<i64>,
pub license: Option<serde_json::Value>,
pub allow_auto_merge: Option<bool>,
pub delete_branch_on_merge: Option<bool>,
pub parent: Option<Box<Repository>>,
pub source: Option<Box<Repository>>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct Subject {
pub title: String,
pub url: Option<String>,
pub latest_comment_url: Option<String>,
pub r#type: String,
}
/// Get new notifications.
/// Returns: notifications and new last-modified value.
pub fn get_new_notifications(
pat: &str,
last_modified: Option<&str>,
) -> Result<(Vec<Notification>, Option<String>), Box<dyn Error>> {
let mut resp = ureq::get("https://api.github.com/notifications").set("Authorization", &format!("Bearer {pat}"));
if let Some(val) = last_modified {
resp = resp.set("If-Modified-Since", val);
}
let json = resp.call()?.into_string()?;
let items: Vec<Notification> = serde_json::from_str(&json)?;
let last_modified = items
.get(0)
.map(|x| x.updated_at.clone())
.or_else(|| last_modified.map(|x| x.to_owned()));
Ok((items, last_modified))
}

View File

@ -17,6 +17,8 @@ use gpiocdev::{
#[cfg(feature = "pc")] #[cfg(feature = "pc")]
use image::{ImageBuffer, Rgb}; use image::{ImageBuffer, Rgb};
pub mod github;
#[cfg(feature = "pc")] #[cfg(feature = "pc")]
pub struct FrameOutput { pub struct FrameOutput {
pub buffer: ImageBuffer<Rgb<u8>, Vec<u8>>, pub buffer: ImageBuffer<Rgb<u8>, Vec<u8>>,
@ -43,7 +45,8 @@ impl DrawTarget for FrameOutput {
{ {
for pos in pixels { for pos in pixels {
if pos.0.x < 0 if pos.0.x < 0
|| pos.0.y < 0 || pos.0.x as u32 >= self.buffer.width() || pos.0.y < 0
|| pos.0.x as u32 >= self.buffer.width()
|| pos.0.y as u32 >= self.buffer.height() || pos.0.y as u32 >= self.buffer.height()
{ {
continue; continue;