Compare commits

..

6 Commits

Author SHA1 Message Date
FliegendeWurst
9c7dc2a897 New bear screensaver 2024-11-18 23:10:57 +01:00
FliegendeWurst
9293d327e0 Truncate long lines 2024-11-09 23:07:57 +01:00
FliegendeWurst
a2eaa5bd52 Also show notification subject 2024-11-09 22:07:47 +01:00
FliegendeWurst
6e0dbcf006 Fix last modified properly 2024-11-08 18:33:26 +01:00
FliegendeWurst
13325554a4 Fix last-modified value 2024-11-08 17:43:09 +01:00
FliegendeWurst
efac4f49a7 New Github notifications thingy 2024-11-08 12:53:33 +01:00
14 changed files with 1379 additions and 487 deletions

1340
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -2,7 +2,7 @@
rustPlatform.buildRustPackage {
pname = "raspi-oled";
version = "unstable-infdev-4";
version = "unstable-infdev-7";
src = ./.;
@ -16,7 +16,7 @@ rustPlatform.buildRustPackage {
nativeBuildInputs = [ pkg-config ];
cargoBuildFlags = [ "--no-default-features" "--bin" "take_measurement" ];
cargoBuildFlags = [ "--no-default-features" "--bin" "main_loop" ];
buildInputs = [ sqlite ];

View File

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

View File

@ -22,8 +22,8 @@ use rppal::{
spi::{Bus, Mode, SlaveSelect, Spi},
};
use rusqlite::Connection;
use schedule::Schedule;
use screensaver::{Screensaver, TimeDisplay};
use schedule::{github_notifications::GithubNotifications, Schedule};
use screensaver::{BearReminder, Screensaver, TimeDisplay};
use ssd1351::display::display::Ssd1351;
use time::OffsetDateTime;
use time_tz::{timezones::db::europe::BERLIN, OffsetDateTimeExt};
@ -37,6 +37,8 @@ pub type Oled = Ssd1351<SPIInterfaceNoCS<Spi, OutputPin>>;
pub type Rng = Xoroshiro128StarStar;
static BLACK: Rgb565 = Rgb565::new(0, 0, 0);
/// Delay after drawing a frame in milliseconds.
const FRAME_INTERVAL: u64 = 66;
fn main() {
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 active_count(&self) -> usize;
@ -58,7 +62,7 @@ pub trait Context {
pub struct ContextDefault<D: DrawTarget<Color = Rgb565>> {
screensavers: Vec<Box<dyn Screensaver<D>>>,
scheduled: Vec<Box<dyn Schedule>>,
scheduled: Vec<Box<dyn Schedule<D>>>,
active: RefCell<Vec<Box<dyn Draw<D>>>>,
database: Rc<RefCell<Connection>>,
}
@ -70,10 +74,17 @@ impl<D: DrawTarget<Color = Rgb565>> ContextDefault<D> {
screensavers.push(Box::new(draw::Measurements::temps()));
screensavers.push(Box::new(draw::Measurements::events()));
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)),
}));
scheduled.push(Box::new(BearReminder::default()));
ContextDefault {
database: Rc::new(RefCell::new(database)),
screensavers,
scheduled: schedule::reminders(),
scheduled,
active: RefCell::new(vec![Box::new(TimeDisplay::new())]),
}
}
@ -112,7 +123,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) {
match action {
Action::Screensaver(id) => {
@ -226,7 +241,7 @@ fn pc_main() {
.unwrap();
// 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;
buffer_dirty = ctx.loop_iter(&mut disp, &mut rng);
}
@ -428,6 +443,6 @@ fn main_loop(mut disp: Oled, mut ctx: ContextDefault<Oled>) {
if dirty {
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

@ -23,5 +23,5 @@ fn main() {
}
fn get_json(url: &str) -> Result<String, Box<dyn Error>> {
Ok(ureq::get(url).call()?.into_string()?)
Ok(ureq::get(url).call()?.into_body().read_to_string()?)
}

View File

@ -0,0 +1,126 @@
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 max_lines = 8;
let max_line_length = 16;
let mut lines = vec![];
let mut relevant = relevant.into_iter();
while lines.len() < max_lines {
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]));
if lines.len() < max_lines {
let mut desc = format!(" {}", x.subject.title);
desc.truncate(desc.floor_char_boundary(max_line_length));
lines.push(desc);
}
} else {
break;
}
}
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_colored(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 crate::{action::Action, Context};
pub mod github_notifications;
/// Task to be executed at certain times.
/// Guaranteed to be checked at least once every minute.
pub trait Schedule {
fn check_and_do(&self, ctx: &dyn Context, time: OffsetDateTime) {
pub trait Schedule<D>
where
D: DrawTarget<Color = Rgb565>,
{
fn check_and_do(&self, ctx: &dyn Context<D>, time: OffsetDateTime) {
if self.check(ctx, time) {
self.execute(ctx, time);
}
}
fn check(&self, ctx: &dyn Context, time: OffsetDateTime) -> bool;
fn execute(&self, ctx: &dyn Context, time: OffsetDateTime);
fn check(&self, ctx: &dyn Context<D>, time: OffsetDateTime) -> bool;
fn execute(&self, ctx: &dyn Context<D>, time: OffsetDateTime);
}
#[derive(Debug, Clone, Copy)]
@ -23,12 +29,12 @@ pub struct Reminder {
should_beep: bool,
}
impl Schedule for Reminder {
fn check(&self, ctx: &dyn Context, time: OffsetDateTime) -> bool {
impl<D: DrawTarget<Color = Rgb565>> Schedule<D> for Reminder {
fn check(&self, ctx: &dyn Context<D>, time: OffsetDateTime) -> bool {
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 {
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 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)]
}

Binary file not shown.

View File

@ -3,6 +3,7 @@ use std::cell::RefCell;
use std::sync::atomic::{AtomicU32, AtomicU64};
use embedded_graphics::mono_font::ascii::FONT_10X20;
use embedded_graphics::prelude::RgbColor;
use embedded_graphics::{
mono_font::MonoTextStyleBuilder,
pixelcolor::Rgb565,
@ -15,6 +16,7 @@ use rand_xoshiro::rand_core::RngCore;
use time::{Duration, OffsetDateTime};
use time_tz::{timezones::db::europe::BERLIN, OffsetDateTimeExt};
use crate::schedule::Schedule;
use crate::{Draw, Rng};
pub static SPEED: AtomicU64 = AtomicU64::new(32);
@ -102,6 +104,44 @@ impl SimpleScreensaver {
iters: AtomicU32::new(0),
}
}
pub fn draw_all_colored<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(())
}
pub fn draw_all<D: DrawTarget<Color = Rgb565>>(&self, disp: &mut D, flipped: bool) -> Result<(), D::Error> {
disp.fill_contiguous(
&Rectangle::new((0, 0).into(), (128, 128).into()),
(0..128 * 128).map(|idx| {
let (mut red, mut green, mut blue) =
(self.data[3 * idx], self.data[3 * idx + 1], self.data[3 * idx + 2]);
if flipped {
red = 255 - red;
green = 255 - green;
blue = 255 - blue;
}
let r = red >> 3;
let g = green >> 2;
let b = blue >> 3;
Rgb565::new(r, g, b)
}),
)?;
Ok(())
}
}
static TIME_COLOR: Rgb565 = Rgb565::new(0b01_111, 0b011_111, 0b01_111);
@ -195,6 +235,8 @@ pub static RPI: SimpleScreensaver = SimpleScreensaver::new("rpi", include_bytes!
pub static DUOLINGO: SimpleScreensaver = SimpleScreensaver::new("duolingo", include_bytes!("./duolingo.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 GITHUB: SimpleScreensaver = SimpleScreensaver::new("github", include_bytes!("./github.raw"));
pub static TEDDY_BEAR: SimpleScreensaver = SimpleScreensaver::new("teddy_bear", include_bytes!("./teddy_bear.raw"));
pub fn screensavers<D: DrawTarget<Color = Rgb565>>() -> Vec<Box<dyn Screensaver<D>>> {
vec![
@ -205,3 +247,52 @@ pub fn screensavers<D: DrawTarget<Color = Rgb565>>() -> Vec<Box<dyn Screensaver<
Box::new(PLATE.clone()),
]
}
pub struct BearReminder;
impl Default for BearReminder {
fn default() -> Self {
Self {}
}
}
impl<D: DrawTarget<Color = Rgb565>> Schedule<D> for BearReminder {
fn check(&self, _ctx: &dyn crate::Context<D>, time: OffsetDateTime) -> bool {
time.hour() == 21 && time.minute() == 30 && time.to_julian_day() % 2 == 1
}
fn execute(&self, ctx: &dyn crate::Context<D>, _time: OffsetDateTime) {
ctx.do_draw(Box::new(BearDraw { calls: RefCell::new(0) }));
}
}
struct BearDraw {
calls: RefCell<usize>,
}
impl<D: DrawTarget<Color = Rgb565>> Draw<D> for BearDraw {
fn draw(&self, disp: &mut D, _rng: &mut Rng) -> Result<bool, <D as DrawTarget>::Error> {
let mut calls = self.calls.borrow_mut();
*calls += 1;
if *calls > 73 {
return Ok(false);
}
TEDDY_BEAR.draw_all(disp, *calls % 8 >= 4)?;
Ok(true)
}
fn expired(&self) -> bool {
*self.calls.borrow() > 110
}
fn as_any(&self) -> &dyn Any {
&*self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
&mut *self
}
}

File diff suppressed because one or more lines are too long

View File

@ -64,7 +64,7 @@ fn main() {
.success();
let sync_good = if nixos_up {
if let Ok(x) = ureq::get("http://nixos.fritz.box:12783/").call() {
x.status() < 400
x.status().as_u16() < 400
} else {
false
}

222
src/github.rs Normal file
View File

@ -0,0 +1,222 @@
use std::error::Error;
use serde::Deserialize;
use time::{macros::format_description, PrimitiveDateTime};
#[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").header("Authorization", &format!("Bearer {pat}"));
if let Some(val) = last_modified {
resp = resp.header("If-Modified-Since", val);
}
let json = resp.call()?.into_body().read_to_string()?;
let items: Vec<Notification> = serde_json::from_str(&json)?;
let new_last_modified = items.get(0).map(|x| x.updated_at.clone());
let last_modified = if let Some(lm) = new_last_modified {
// parse and increase by five seconds
let format = format_description!("[year]-[month]-[day]T[hour]:[minute]:[second]Z");
let mut dt = PrimitiveDateTime::parse(&lm, format)?;
dt += time::Duration::seconds(5);
Some(dt.format(&format)?)
} else {
last_modified.map(|x| x.to_owned())
};
Ok((items, last_modified))
}

View File

@ -4,6 +4,7 @@ use std::{
time::{self, Duration},
};
#[cfg(feature = "pc")]
use embedded_graphics::{
draw_target::DrawTarget,
pixelcolor::Rgb565,
@ -17,6 +18,8 @@ use gpiocdev::{
#[cfg(feature = "pc")]
use image::{ImageBuffer, Rgb};
pub mod github;
#[cfg(feature = "pc")]
pub struct FrameOutput {
pub buffer: ImageBuffer<Rgb<u8>, Vec<u8>>,
@ -43,7 +46,8 @@ impl DrawTarget for FrameOutput {
{
for pos in pixels {
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()
{
continue;