summary history branches tags files
commit:aa96568e6d1234aead58e5818cad11f99060ea61
author:Trevor Bentley
committer:Trevor Bentley
date:Fri Jun 16 18:35:53 2017 +0200
parents:e9970c17c73bba2d0cb66ddfdb48a7187fa27fab
Add API for registering multiple alarm clocks (Daily, Weekdays, or Weekends)
diff --git a/.gitmodules b/.gitmodules
line changes: +3/-0
index 8dec61c..1798e5d
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,6 @@
 [submodule "deps/systray-rs"]
 	path = deps/systray-rs
 	url = https://github.com/mrmekon/systray-rs
+[submodule "deps/timer.rs"]
+	path = deps/timer.rs
+	url = git@github.com:mrmekon/timer.rs.git

diff --git a/Cargo.toml b/Cargo.toml
line changes: +2/-2
index 07b8293..f627e9a
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -41,8 +41,8 @@ serde_derive = "1.0.5"
 url = "1.4.0"
 rust-ini = "0.9"
 time = "0.1"
-timer = "0.1.6"
-chrono = "0.3.0"
+timer = {path = "deps/timer.rs", version="0.1.4"} # Modified for chrono 0.3.1
+chrono = "0.3"
 log = "0.3.7"
 log4rs = "0.6.3"
 ctrlc = "3.0.1"

diff --git a/deps/timer.rs b/deps/timer.rs
line changes: +1/-0
index 0000000..4f77583
--- /dev/null
+++ b/deps/timer.rs
@@ -0,0 +1 @@
+Subproject commit 4f7758302a8b2d1c0446e4d9d51e01b497b778df

diff --git a/src/webapi/mod.rs b/src/webapi/mod.rs
line changes: +101/-1
index d7b8b25..f7ab374
--- a/src/webapi/mod.rs
+++ b/src/webapi/mod.rs
@@ -3,7 +3,9 @@ mod test;
 
 extern crate time;
 extern crate timer;
+
 extern crate chrono;
+use self::chrono::{DateTime, Local, UTC, TimeZone, Datelike, Timelike, Weekday};
 
 use std::fmt;
 use std::iter;
@@ -270,6 +272,22 @@ impl QueryString {
     }
 }
 
+#[derive(PartialEq)]
+pub enum AlarmRepeat {
+    Daily,
+    Weekdays,
+    Weekends,
+}
+
+pub struct AlarmEntry {
+    pub time: String,
+    pub repeat: AlarmRepeat,
+    pub context: PlayContext,
+    pub device: DeviceId,
+    #[cfg(test)]
+    pub now: Option<DateTime<Local>>,
+}
+
 pub enum SpotifyRepeat {
     Off,
     Track,
@@ -296,6 +314,13 @@ struct DeviceIdList {
     play: bool,
 }
 
+struct AlarmTimer {
+    entry: AlarmEntry,
+    timer: timer::Timer,
+    guard: timer::Guard,
+    channel: Receiver<()>,
+}
+
 pub struct SpotifyConnectr<'a> {
     api: SpotifyEndpoints<'a>,
     settings: settings::Settings,
@@ -308,6 +333,8 @@ pub struct SpotifyConnectr<'a> {
     refresh_timer: timer::Timer,
     refresh_timer_guard: Option<timer::Guard>,
     refresh_timer_channel: Option<Receiver<()>>,
+
+    alarms: Vec<AlarmTimer>,
 }
 impl<'a> Default for SpotifyConnectr<'a> {
     fn default() -> Self {
@@ -353,7 +380,9 @@ impl<'a> SpotifyConnectrBuilder<'a> {
                               device: None,
                               refresh_timer: timer::Timer::new(),
                               refresh_timer_guard: None,
-                              refresh_timer_channel: None})
+                              refresh_timer_channel: None,
+                              alarms: Vec::new(),
+        })
     }
     #[cfg(test)]
     fn with_api(&mut self, api: SpotifyEndpoints<'a>) -> &mut Self {
@@ -394,6 +423,63 @@ impl<'a> SpotifyConnectr<'a> {
             _ => 0,
         }
     }
+    fn alarm_current_time(&self, entry: &AlarmEntry) -> DateTime<Local> {
+        // Allow unit tests to override 'now', so days can be deterministic
+        #[cfg(test)]
+        return match entry.now {
+            Some(dt) => dt.clone(),
+            None => Local::now(),
+        };
+        Local::now()
+    }
+    fn next_alarm_datetime(&self, entry: &AlarmEntry) -> Result<DateTime<Local>, String> {
+        let now = self.alarm_current_time(&entry);
+        let format_12h = "%I:%M %p";
+        let format_24h = "%H:%M";
+        let alarm_time = match time::strptime(&entry.time, format_24h) {
+            Ok(t) => t,
+            _ => return Err("Could not parse alarm clock time.".to_string()),
+        };
+        let mut alarm = self.alarm_current_time(&entry)
+            .with_hour(alarm_time.tm_hour as u32).ok_or("Invalid hour")?
+            .with_minute(alarm_time.tm_min as u32).ok_or("Invalid minute")?
+            .with_second(0).ok_or("Invalid second")?
+            .with_nanosecond(0).ok_or("Invalid nanosecond")?;
+        // Increment by one day if the current hour has already passed
+        if alarm < now {
+            alarm = alarm + chrono::Duration::days(1);
+        }
+        // Increment until a day is found that matches the alarm repeat options
+        loop {
+            let is_weekday = match alarm.weekday() {
+                Weekday::Sat | Weekday::Sun => false,
+                _ => true,
+            };
+            if entry.repeat == AlarmRepeat::Daily ||
+                (is_weekday && entry.repeat == AlarmRepeat::Weekdays) ||
+                (!is_weekday && entry.repeat == AlarmRepeat::Weekends) {
+                    break;
+            }
+            alarm = alarm + chrono::Duration::days(1);
+        }
+        Ok(alarm)
+    }
+    pub fn schedule_alarm(&mut self, entry: AlarmEntry) -> Result<DateTime<Local>, ()> {
+        let alarm = self.next_alarm_datetime(&entry).unwrap();
+        let (tx, rx) = channel::<>();
+        let timer = timer::Timer::new();
+        let closure = move || { tx.send(()).unwrap(); };
+        let guard = timer.schedule_with_date(alarm, closure);
+        let duration = alarm.signed_duration_since(Local::now());
+        info!("Alarm set for {} hours from now", duration.num_hours());
+        self.alarms.push(AlarmTimer {
+            entry: entry,
+            timer: timer,
+            guard: guard,
+            channel: rx,
+        });
+        Ok(alarm)
+    }
     fn schedule_token_refresh(&mut self) -> Result<(), ()> {
         match self.expire_utc {
             Some(expire_utc) => {
@@ -444,6 +530,20 @@ impl<'a> SpotifyConnectr<'a> {
             true  => Box::new(move |rx| { match rx.recv() { Ok(_) => true, Err(_) => false } }),
             false => Box::new(move |rx| { match rx.try_recv() { Ok(_) => true, Err(_) => false } }),
         };
+
+        // Get list of which alarms have expired
+        let expired = self.alarms.iter().enumerate()
+            .map(|(idx, alarm)| alarm.channel.try_recv().is_ok()).collect::<Vec<_>>();
+        // For each expired alarm, remove it, execute it, and reschedule it
+        for (idx, exp) in expired.iter().enumerate() {
+            if *exp {
+                let old_alarm = self.alarms.swap_remove(idx);
+                self.set_target_device(Some(old_alarm.entry.device.clone()));
+                self.play(Some(&old_alarm.entry.context));
+                self.schedule_alarm(old_alarm.entry);
+            }
+        }
+
         let need_refresh = match self.refresh_timer_channel.as_ref() {
             Some(rx) => recv_fn(rx),
             _ => false,

diff --git a/src/webapi/test.rs b/src/webapi/test.rs
line changes: +63/-0
index 5653699..d935018
--- a/src/webapi/test.rs
+++ b/src/webapi/test.rs
@@ -5,6 +5,9 @@ mod tests {
     extern crate fruitbasket;
     extern crate time;
 
+    extern crate chrono;
+    use self::chrono::UTC;
+
     use super::super::*;
     use super::super::super::SpotifyEndpoints;
 
@@ -177,4 +180,64 @@ mod tests {
         }
     }
 
+    fn build_alarm_entry(time: &str, repeat: AlarmRepeat, now: DateTime<Local>) -> AlarmEntry {
+        return AlarmEntry {
+            time: time.to_string(),
+            repeat: repeat,
+            context: PlayContext::new().build(),
+            device: "12345".to_string(),
+            now: Some(now),
+        }
+    }
+
+    #[test]
+    fn test_alarm_scheduler() {
+        let mut spotify = SpotifyConnectr::new().with_api(TEST_API);
+
+        // From a Friday morning
+        let today = Local.ymd(2017, 06, 16).and_hms_milli(9, 00, 00, 0);
+
+        // Daily, later the same day
+        let entry = build_alarm_entry("21:00", AlarmRepeat::Daily, today.clone());
+        let alarm = spotify.schedule_alarm(entry).unwrap();
+        assert_eq!(alarm.format("%Y-%m-%d %H:%M:%S").to_string(), "2017-06-16 21:00:00");
+
+        // Daily, the next day
+        let entry = build_alarm_entry("08:00", AlarmRepeat::Daily, today.clone());
+        let alarm = spotify.schedule_alarm(entry).unwrap();
+        assert_eq!(alarm.format("%Y-%m-%d %H:%M:%S").to_string(), "2017-06-17 08:00:00");
+
+        // Weekends, the next day
+        let entry = build_alarm_entry("08:00", AlarmRepeat::Weekends, today.clone());
+        let alarm = spotify.schedule_alarm(entry).unwrap();
+        assert_eq!(alarm.format("%Y-%m-%d %H:%M:%S").to_string(), "2017-06-17 08:00:00");
+
+        // Weekdays, the next week
+        let entry = build_alarm_entry("08:00", AlarmRepeat::Weekdays, today.clone());
+        let alarm = spotify.schedule_alarm(entry).unwrap();
+        assert_eq!(alarm.format("%Y-%m-%d %H:%M:%S").to_string(), "2017-06-19 08:00:00");
+
+        // From a Wednesday night
+        let today = Local.ymd(2017, 06, 21).and_hms_milli(21, 00, 00, 0);
+
+        // Daily, later the same day
+        let entry = build_alarm_entry("23:00", AlarmRepeat::Daily, today.clone());
+        let alarm = spotify.schedule_alarm(entry).unwrap();
+        assert_eq!(alarm.format("%Y-%m-%d %H:%M:%S").to_string(), "2017-06-21 23:00:00");
+
+        // Daily, the next day
+        let entry = build_alarm_entry("00:00", AlarmRepeat::Daily, today.clone());
+        let alarm = spotify.schedule_alarm(entry).unwrap();
+        assert_eq!(alarm.format("%Y-%m-%d %H:%M:%S").to_string(), "2017-06-22 00:00:00");
+
+        // Weekends, 3 days later
+        let entry = build_alarm_entry("08:00", AlarmRepeat::Weekends, today.clone());
+        let alarm = spotify.schedule_alarm(entry).unwrap();
+        assert_eq!(alarm.format("%Y-%m-%d %H:%M:%S").to_string(), "2017-06-24 08:00:00");
+
+        // Weekdays, the next day
+        let entry = build_alarm_entry("08:00", AlarmRepeat::Weekdays, today.clone());
+        let alarm = spotify.schedule_alarm(entry).unwrap();
+        assert_eq!(alarm.format("%Y-%m-%d %H:%M:%S").to_string(), "2017-06-22 08:00:00");
+    }
 }