diff --git a/crates/samedec/src/spawner.rs b/crates/samedec/src/spawner.rs index 8cd3028..3afaa72 100644 --- a/crates/samedec/src/spawner.rs +++ b/crates/samedec/src/spawner.rs @@ -31,14 +31,15 @@ where B: AsRef, A: IntoIterator, { - let (issue_ts, purge_ts) = match header.issue_datetime(&Utc::now()) { - Ok(issue_ts) => ( - time_to_unix_str(issue_ts), - time_to_unix_str(issue_ts + header.valid_duration()), - ), - Err(_e) => ("".to_owned(), "".to_owned()), - }; - + let now = Utc::now(); + let issue_ts = header + .issue_datetime(&now) + .map(|tm| time_to_unix_str(tm)) + .unwrap_or_default(); + let purge_ts = header + .purge_datetime(&now) + .map(|tm| time_to_unix_str(tm)) + .unwrap_or_default(); let locations: Vec<&str> = header.location_str_iter().collect(); let evt = header.event(); diff --git a/crates/sameplace/src/message.rs b/crates/sameplace/src/message.rs index f9818cc..4ec398a 100644 --- a/crates/sameplace/src/message.rs +++ b/crates/sameplace/src/message.rs @@ -389,26 +389,36 @@ impl MessageHeader { self.location_str().split('-') } - /// Message validity duration (Duration) + /// Message validity duration (`Duration`) /// - /// Returns the message validity duration. The message is - /// valid until + /// Returns the message validity duration or "purge time." + /// The duration specifies how long, relative to the + /// [issue time](MessageHeader::issue_datetime), that the + /// message is valid. /// - /// ```ignore - /// msg.issue_datetime().unwrap() + msg.valid_duration() - /// ``` + /// The Duration is typically: + /// + /// * increments of **15 minutes** for Durations of + /// **1 hour** or less + /// + /// * increments of **30 minutes** for Durations longer + /// than **1 hour** + /// + /// * no longer than + /// [99.5 hours](https://www.weather.gov/nwr/samealertduration) /// - /// After this time elapses, the message is no longer valid - /// and should not be relayed or alerted to anymore. + /// but sameplace does not enforce any of these restrictions. /// - /// This field represents the validity time of the *message* + /// This field represents the validity duration of the *message* /// and not the expected duration of the severe condition. - /// Severe conditions may persist after the message expires! - /// (And might be the subject of future messages.) + /// **An expired message may still refer to an ongoing hazard** or + /// event. Expiration merely indicates that the *message* is no + /// longer valid. Clients are encouraged to retain a history of + /// alerts and voice message contents. /// /// The valid duration is relative to the - /// [`issue_datetime()`](#method.issue_datetime) and *not* the - /// current time. + /// [`issue_datetime()`](MessageHeader::issue_datetime) and + /// *not* the current time. /// /// Requires `chrono`. #[cfg(feature = "chrono")] @@ -421,14 +431,33 @@ impl MessageHeader { /// /// Returns the message validity duration or "purge time." /// This is a tuple of (`hours`, `minutes`). + /// The duration specifies how long, relative to the + /// [issue time](MessageHeader::issue_datetime), that the + /// message is valid. + /// + /// The duration is typically: + /// + /// * increments of **15 minutes** for durations of + /// **1 hour** or less + /// + /// * increments of **30 minutes** for durations longer + /// than **1 hour** + /// + /// * no longer than + /// [99.5 hours](https://www.weather.gov/nwr/samealertduration) /// - /// This field represents the validity time of the *message* + /// but sameplace does not enforce any of these restrictions. + /// + /// This field represents the validity duration of the *message* /// and not the expected duration of the severe condition. - /// Severe conditions may persist after the message expires! - /// (And might be the subject of future messages.) + /// **An expired message may still refer to an ongoing hazard** or + /// event. Expiration merely indicates that the *message* is no + /// longer valid. Clients are encouraged to retain a history of + /// alerts and voice message contents. /// /// The valid duration is relative to the - /// [`issue_daytime_fields()`](#method.issue_daytime_fields). + /// [`issue_datetime()`](MessageHeader::issue_datetime) and + /// *not* the current time. pub fn valid_duration_fields(&self) -> (u8, u8) { let dur_str = &self.message[self.offset_time + Self::OFFSET_FROMPLUS_VALIDTIME ..self.offset_time + Self::OFFSET_FROMPLUS_VALIDTIME + 4]; @@ -471,28 +500,73 @@ impl MessageHeader { ) } + /// Message purge/expiration datetime (UTC) + /// + /// Compute the datetime that the SAME message should be + /// *purged* or discarded. The caller must provide the time + /// that the message was `received`. + /// + /// The returned timestamp is rounded per NWSI 10-1712: + /// + /// * For [valid durations](MessageHeader::valid_duration) ≤01h00m, + /// the timestamp is rounded to the nearest 15 minutes + /// + /// * For valid durations greater than an hour, the timestamp is + /// rounded to the nearest 30 minutes + /// + /// An error is returned if we are unable to calculate + /// a valid timestamp. This can happen, for example, if we + /// project a message sent on Julian/Ordinal Day 366 into a + /// year that is not a leap year. + /// + /// SAME headers do not include the year of issuance. This makes + /// it impossible to calculate the full datetime of issuance—or + /// purge, for that matter—without a rough idea of the message's + /// true UTC time. It is *unnecessary* for the `received` time + /// to be a precision timestamp. As long as the provided value + /// is within ±90 days of true UTC, the output time will be + /// correct. + /// + /// This field represents the expiration time of the *message* + /// and not the expected duration of the severe condition. + /// **An expired message may still refer to an ongoing hazard** or + /// event. Expiration merely indicates that the *message* is no + /// longer valid. Clients are encouraged to retain a history of + /// alerts and voice message contents. + /// + /// Requires `chrono`. + #[cfg(feature = "chrono")] + pub fn purge_datetime( + &self, + received: &DateTime, + ) -> Result, InvalidDateErr> { + calculate_expire_time(&self.issue_datetime(received)?, &self.valid_duration()) + } + /// Is the message expired? /// /// Given the current time, determine if this message has - /// expired. It is assumed that `now` is within twelve - /// hours of the message issuance time. Twelve hours is - /// the maximum [`duration`](#method.valid_duration) of a - /// SAME message. + /// expired. It is assumed that `now` is within ±90 days of + /// the message's [issuance time](MessageHeader::issue_datetime). + /// The [maximum duration](https://www.weather.gov/nwr/samealertduration) + /// of a SAME message is 99.5 hours. /// - /// An expired message may still refer to an *ongoing hazard* - /// or event! Expiration merely indicates that the message - /// should not be relayed or alerted to anymore. + /// **An expired message may still refer to an ongoing hazard** or + /// event. Expiration merely indicates that the *message* is no + /// longer valid. Clients are encouraged to retain a history of + /// alerts and voice message contents. /// /// Requires `chrono`. #[cfg(feature = "chrono")] pub fn is_expired_at(&self, now: &DateTime) -> bool { - match self.issue_datetime(now) { - Ok(issue_ts) => issue_ts + self.valid_duration() < *now, - Err(_e) => false, + if let Ok(purge) = self.purge_datetime(&now) { + purge < *now + } else { + false } } - /// Mesage issuance day/time (fields) + /// Message issuance day/time (fields) /// /// Returns the message issue day and time, as the string /// `JJJHHMM`, @@ -787,6 +861,31 @@ fn calculate_issue_time( .ok_or(InvalidDateErr {}) } +/// Calculate message expiration time +#[cfg(feature = "chrono")] +fn calculate_expire_time( + issued: &DateTime, + purge: &Duration, +) -> Result, InvalidDateErr> { + use chrono::DurationRound; + + const FIFTEEN_MINUTES: Duration = Duration::minutes(15); + const THIRTY_MINUTES: Duration = Duration::minutes(30); + const ONE_HOUR: Duration = Duration::hours(1); + + issued + .checked_add_signed(*purge) + .and_then(|purge_unrounded| { + if *purge <= ONE_HOUR { + purge_unrounded.duration_round(FIFTEEN_MINUTES) + } else { + purge_unrounded.duration_round(THIRTY_MINUTES) + } + .ok() + }) + .ok_or(InvalidDateErr {}) +} + // Create the latest-possible Utc date from year, ordinal, and HMS #[cfg(feature = "chrono")] #[inline] @@ -863,9 +962,65 @@ mod tests { calculate_issue_time((84, 25, 59), (2021, 84)).expect_err("should not succeed"); } + #[cfg(feature = "chrono")] + #[test] + fn test_calculate_expire_time_short() { + const FIFTEEN_MINUTES: Duration = Duration::minutes(15); + + let issued = Utc.with_ymd_and_hms(2021, 3, 24, 2, 44, 0).unwrap(); + assert_eq!( + Utc.with_ymd_and_hms(2021, 3, 24, 3, 0, 0).unwrap(), + calculate_expire_time(&issued, &FIFTEEN_MINUTES).unwrap() + ); + + let issued = Utc.with_ymd_and_hms(2021, 3, 24, 2, 46, 0).unwrap(); + assert_eq!( + Utc.with_ymd_and_hms(2021, 3, 24, 3, 0, 0).unwrap(), + calculate_expire_time(&issued, &FIFTEEN_MINUTES).unwrap() + ); + + let issued = Utc.with_ymd_and_hms(2021, 3, 24, 2, 55, 0).unwrap(); + assert_eq!( + Utc.with_ymd_and_hms(2021, 3, 24, 3, 15, 0).unwrap(), + calculate_expire_time(&issued, &FIFTEEN_MINUTES).unwrap() + ); + + let issued = Utc.with_ymd_and_hms(2021, 3, 24, 3, 00, 0).unwrap(); + assert_eq!( + Utc.with_ymd_and_hms(2021, 3, 24, 3, 15, 0).unwrap(), + calculate_expire_time(&issued, &FIFTEEN_MINUTES).unwrap() + ); + } + + #[cfg(feature = "chrono")] + #[test] + fn test_calculate_expire_time_long() { + let issued = Utc.with_ymd_and_hms(2021, 3, 24, 2, 53, 0).unwrap(); + + assert_eq!( + Utc.with_ymd_and_hms(2021, 3, 24, 3, 15, 0).unwrap(), + calculate_expire_time(&issued, &Duration::minutes(15)).unwrap() + ); + + assert_eq!( + Utc.with_ymd_and_hms(2021, 3, 24, 3, 30, 0).unwrap(), + calculate_expire_time(&issued, &Duration::minutes(30)).unwrap() + ); + + assert_eq!( + Utc.with_ymd_and_hms(2021, 3, 24, 3, 45, 0).unwrap(), + calculate_expire_time(&issued, &Duration::minutes(45)).unwrap() + ); + + assert_eq!( + Utc.with_ymd_and_hms(2021, 3, 24, 4, 00, 0).unwrap(), + calculate_expire_time(&issued, &Duration::minutes(60)).unwrap() + ); + } + #[test] fn test_message_header() { - const THREE_LOCATIONS: &str = "ZCZC-WXR-RWT-012345-567890-888990+0351-3662322-NOCALL00-@@@"; + const THREE_LOCATIONS: &str = "ZCZC-WXR-RWT-012345-567890-888990+0330-3662322-NOCALL00-@@@"; let mut errs = vec![0u8; THREE_LOCATIONS.len()]; errs[0] = 1u8; @@ -885,7 +1040,7 @@ mod tests { assert_eq!(Originator::NationalWeatherService, msg.originator()); assert_eq!(msg.event_str(), "RWT"); assert_eq!(msg.event().phenomenon(), Phenomenon::RequiredWeeklyTest); - assert_eq!(msg.valid_duration_fields(), (3, 51)); + assert_eq!(msg.valid_duration_fields(), (3, 30)); assert_eq!(msg.issue_daytime_fields(), (366, 23, 22)); assert_eq!(msg.callsign(), "NOCALL00"); assert_eq!(msg.parity_error_count(), 6); @@ -898,19 +1053,25 @@ mod tests { // time API checks #[cfg(feature = "chrono")] { + // mock system time that the message was received + let received = Utc.with_ymd_and_hms(2020, 12, 31, 11, 30, 34).unwrap(); + assert_eq!( Utc.with_ymd_and_hms(2020, 12, 31, 23, 22, 00).unwrap(), - msg.issue_datetime(&Utc.with_ymd_and_hms(2020, 12, 31, 11, 30, 34).unwrap()) - .unwrap() + msg.issue_datetime(&received).unwrap() ); assert_eq!( msg.valid_duration(), - Duration::hours(3) + Duration::minutes(51) + Duration::hours(3) + Duration::minutes(30) + ); + assert_eq!( + Utc.with_ymd_and_hms(2021, 1, 1, 3, 0, 00).unwrap(), + msg.purge_datetime(&received).unwrap() ); assert!(!msg.is_expired_at(&Utc.with_ymd_and_hms(2020, 12, 31, 23, 59, 0).unwrap())); assert!(!msg.is_expired_at(&Utc.with_ymd_and_hms(2021, 1, 1, 1, 20, 30).unwrap())); - assert!(!msg.is_expired_at(&Utc.with_ymd_and_hms(2021, 1, 1, 3, 13, 00).unwrap())); - assert!(msg.is_expired_at(&Utc.with_ymd_and_hms(2021, 1, 1, 3, 13, 01).unwrap())); + assert!(!msg.is_expired_at(&Utc.with_ymd_and_hms(2021, 1, 1, 2, 59, 59).unwrap())); + assert!(msg.is_expired_at(&Utc.with_ymd_and_hms(2021, 1, 1, 3, 0, 01).unwrap())); } // try again via Message diff --git a/sample/npt.22050.s16le.sh b/sample/npt.22050.s16le.sh index 8bf6409..739b308 100644 --- a/sample/npt.22050.s16le.sh +++ b/sample/npt.22050.s16le.sh @@ -13,6 +13,6 @@ exec 0>/dev/null [ "$SAMEDEC_IS_NATIONAL" = "Y" ] lifetime=$(( SAMEDEC_PURGETIME - SAMEDEC_ISSUETIME)) -[ "$lifetime" -eq $(( 30*60 )) ] +[ "$lifetime" -eq $(( 25*60 )) ] echo "+OK" diff --git a/sample/two_and_two.22050.s16le.sh b/sample/two_and_two.22050.s16le.sh index a180011..0b6f7bd 100644 --- a/sample/two_and_two.22050.s16le.sh +++ b/sample/two_and_two.22050.s16le.sh @@ -12,6 +12,6 @@ exec 0>/dev/null [ "$SAMEDEC_IS_NATIONAL" = "" ] lifetime=$(( SAMEDEC_PURGETIME - SAMEDEC_ISSUETIME)) -[ "$lifetime" -eq $(( 1*60*60 + 30*60 )) ] +[ "$lifetime" -eq $(( 1*60*60 + 36*60 )) ] echo "+OK"