diff --git a/Cargo.lock b/Cargo.lock index 07623b3..91f43cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -605,6 +605,7 @@ dependencies = [ "log", "pretty_env_logger", "sameold", + "sameplace", "terminal_size", ] @@ -615,18 +616,12 @@ dependencies = [ "arraydeque", "arrayvec", "assert_approx_eq", - "chrono", - "lazy_static", "log", "nalgebra", "num-complex", "num-traits", - "phf", - "regex", + "sameplace", "slice-ring-buffer", - "strum", - "strum_macros", - "thiserror", ] [[package]] diff --git a/crates/samedec/Cargo.toml b/crates/samedec/Cargo.toml index ae4855e..9ad83d4 100644 --- a/crates/samedec/Cargo.toml +++ b/crates/samedec/Cargo.toml @@ -12,6 +12,7 @@ readme = "README.md" [dependencies] sameold = {path = "../sameold", version = "^0.5.0"} +sameplace = {path = "../sameplace", version = "^0.1.0", features = ["chrono"]} anyhow = "^1" byteorder = "^1.4" clap = {version = "^4.5", features = ["color", "derive", "wrap_help"]} diff --git a/crates/sameold/Cargo.toml b/crates/sameold/Cargo.toml index bdd48eb..a220bd8 100644 --- a/crates/sameold/Cargo.toml +++ b/crates/sameold/Cargo.toml @@ -11,28 +11,18 @@ repository = "https://github.com/cbs228/sameold.git" readme = "README.md" [dependencies] +sameplace = {path = "../sameplace", version = "^0.1"} arrayvec = "^0.7.1" arraydeque = "^0.5" -lazy_static = "^1.4.0" log = "0.4" nalgebra = "^0.33.2" num-complex = "^0.4" num-traits = "^0.2" -phf = {version = "^0.11", features = ["macros"]} -regex = "^1.5.5" slice-ring-buffer = "^0.3" -strum = "^0.26" -strum_macros = "^0.26" -thiserror = "^2.0" [dev-dependencies] assert_approx_eq = "1.1.0" -[dependencies.chrono] -version = "^0.4" -default-features = false -features = ["clock", "std"] -optional = true - [features] default = ["chrono"] +chrono = ["sameplace/chrono"] diff --git a/crates/sameold/README.md b/crates/sameold/README.md index 13e2c64..8fbbeb2 100644 --- a/crates/sameold/README.md +++ b/crates/sameold/README.md @@ -94,10 +94,10 @@ is decoded. | 3 | Error correction (bit voting) | SAME messages are always transmitted three times, in separate "bursts," for -redundancy. When decoding the start of message *headers* (`ZCZC`), `samedec` +redundancy. When decoding the start of message *headers* (`ZCZC`), `sameold` will use all three bursts together to improve decoding—if possible. -If one retransmission is missed, `samedec` will automatically fall back to +If one retransmission is missed, `sameold` will automatically fall back to decoding with only two bursts. The decoder imposes a delay of approximately **1.311 seconds** on all received headers. This delay is not usually problematic as most SAME messages are prefixed with a Warning Alarm Tone that @@ -129,6 +129,11 @@ carrier signals before and during message decoding. ### Interpreting Messages +> Message decoding and interpretation is provided by the +> [`sameplace`](https://docs.rs/sameplace/latest/sameplace/) +> crate. `sameold` re-exports the `sameplace::Message` API +> for ease-of-use. + The [`Message`](https://docs.rs/sameold/latest/sameold/enum.Message.html) type marks the start or end of a SAME message. The actual "message" part of a SAME message is the audio itself, which should contain a voice message that diff --git a/crates/sameold/src/eventcodes.rs b/crates/sameold/src/eventcodes.rs deleted file mode 100644 index d831ee4..0000000 --- a/crates/sameold/src/eventcodes.rs +++ /dev/null @@ -1,248 +0,0 @@ -//! # List of SAME Events Codes Known to `sameold` -//! -//! | `XYZ` | Description | -//! |-------|----------------------------------------| -//! | `ADR` | Administrative Message | -//! | `AVA` | Avalanche Watch | -//! | `AVW` | Avalanche Warning | -//! | `BLU` | Blue Alert | -//! | `BZW` | Blizzard Warning | -//! | `CAE` | Child Abduction Emergency | -//! | `CDW` | Civil Danger Warning | -//! | `CEM` | Civil Emergency Message | -//! | `CFA` | Coastal Flood Watch | -//! | `CFW` | Coastal Flood Warning | -//! | `DMO` | Practice/Demo Warning | -//! | `DSW` | Dust Storm Warning | -//! | `EAN` | National Emergency Message | -//! | `EQW` | Earthquake Warning | -//! | `EVI` | Evacuation Immediate | -//! | `EWW` | Extreme Wind Warning | -//! | `FFA` | Flash Flood Watch | -//! | `FFS` | Flash Flood Statement | -//! | `FFW` | Flash Flood Warning | -//! | `FLA` | Flood Watch | -//! | `FLS` | Flood Statement | -//! | `FLW` | Flood Warning | -//! | `FRW` | Fire Warning | -//! | `FSW` | Flash Freeze Warning | -//! | `FZW` | Freeze Warning | -//! | `HLS` | Hurricane Local Statement | -//! | `HMW` | Hazardous Materials Warning | -//! | `HUA` | Hurricane Watch | -//! | `HUW` | Hurricane Warning | -//! | `HWA` | High Wind Watch | -//! | `HWW` | High Wind Warning | -//! | `LAE` | Local Area Emergency | -//! | `LEW` | Law Enforcement Warning | -//! | `NAT` | National Audible Test | -//! | `NIC` | National Information Center | -//! | `NMN` | Network Notification Message | -//! | `NPT` | National Periodic Test | -//! | `NST` | National Silent Test | -//! | `NUW` | Nuclear Power Plant Warning | -//! | `RHW` | Radiological Hazard Warning | -//! | `RMT` | Required Monthly Test | -//! | `RWT` | Required Weekly Test | -//! | `SMW` | Special Marine Warning | -//! | `SPS` | Special Weather Statement | -//! | `SPW` | Shelter In-Place warning | -//! | `SQW` | Snow Squall Warning | -//! | `SSA` | Storm Surge Watch | -//! | `SSW` | Storm Surge Warning | -//! | `SVA` | Severe Thunderstorm Watch | -//! | `SVR` | Severe Thunderstorm Warning | -//! | `SVS` | Severe Weather Statement | -//! | `TOA` | Tornado Watch | -//! | `TOE` | 911 Telephone Outage Emergency | -//! | `TOR` | Tornado Warning | -//! | `TRA` | Tropical Storm Watch | -//! | `TRW` | Tropical Storm Warning | -//! | `TSA` | Tsunami Watch | -//! | `TSW` | Tsunami Warning | -//! | `VOW` | Volcano Warning | -//! | `WSA` | Winter Storm Watch | -//! | `WSW` | Winter Storm Warning | -//! -//! SAME event codes for the United States are given in -//! [NWSI 10-1712](https://www.nws.noaa.gov/directives/sym/pd01017012curr.pdf). -//! -//! ## See Also -//! -//! * [`EventCode`](crate::EventCode) -//! * [`MessageHeader::event()`](crate::MessageHeader::event) - -use phf::phf_map; - -use crate::{Phenomenon, SignificanceLevel}; - -/// An entry in [`CODEBOOK`]. -pub(crate) type CodeEntry = (Phenomenon, SignificanceLevel); - -/// Lookup a three-character SAME event code in the database -/// -/// If the input `code` matches a `CodeEntry` that is known to -/// sameold, returns it. If no exact match could be found, the -/// third character is matched as a significance level only. If -/// even that does not match, returns `None`. -pub(crate) fn parse_event(code: S) -> Option -where - S: AsRef, -{ - let code = code.as_ref(); - if code.len() != 3 { - // invalid - return None; - } - - // try the full three-character code first - lookup_threecharacter(code) - // if not, lookup the two-character code + significance - .or_else(|| lookup_twocharacter(code)) - // if not, is the last character a known SignificanceLevel? - .or_else(|| lookup_onecharacter(code)) - // otherwise → None -} - -/// Database of three-character SAME event codes. -/// -/// All three-character codes imply a significance level: -/// the `RWT` will always have a significance of `Test`. -static CODEBOOK3: phf::Map<&'static str, CodeEntry> = phf_map! { - // national activations - "EAN" => (Phenomenon::NationalEmergency, SignificanceLevel::Warning), - "NIC" => (Phenomenon::NationalInformationCenter, SignificanceLevel::Statement), - - // tests - "DMO" => (Phenomenon::PracticeDemoWarning, SignificanceLevel::Warning), - "NAT" => (Phenomenon::NationalAudibleTest, SignificanceLevel::Test), - "NPT" => (Phenomenon::NationalPeriodicTest, SignificanceLevel::Test), - "NST" => (Phenomenon::NationalSilentTest, SignificanceLevel::Test), - "RMT" => (Phenomenon::RequiredMonthlyTest, SignificanceLevel::Test), - "RWT" => (Phenomenon::RequiredWeeklyTest, SignificanceLevel::Test), - - // civil authority codes - "ADR" => (Phenomenon::AdministrativeMessage, SignificanceLevel::Statement), - "BLU" => (Phenomenon::BlueAlert, SignificanceLevel::Warning), - "CAE" => (Phenomenon::ChildAbduction, SignificanceLevel::Emergency), - "CDW" => (Phenomenon::CivilDanger, SignificanceLevel::Warning), - "CEM" => (Phenomenon::CivilEmergency, SignificanceLevel::Warning), - "EQW" => (Phenomenon::Earthquake, SignificanceLevel::Warning), - "EVI" => (Phenomenon::Evacuation, SignificanceLevel::Warning), - "FRW" => (Phenomenon::Fire, SignificanceLevel::Warning), - "HMW" => (Phenomenon::HazardousMaterials, SignificanceLevel::Warning), - "LAE" => (Phenomenon::LocalAreaEmergency, SignificanceLevel::Emergency), - "LEW" => (Phenomenon::LawEnforcementWarning, SignificanceLevel::Warning), - "NMN" => (Phenomenon::NetworkMessageNotification, SignificanceLevel::Statement), - "NUW" => (Phenomenon::NuclearPowerPlant, SignificanceLevel::Warning), - "RHW" => (Phenomenon::RadiologicalHazard, SignificanceLevel::Warning), - "SPW" => (Phenomenon::ShelterInPlace, SignificanceLevel::Warning), - "TOE" => (Phenomenon::TelephoneOutage, SignificanceLevel::Emergency), - "VOW" => (Phenomenon::Volcano, SignificanceLevel::Warning), - - // weather codes, three-character - "HLS" => (Phenomenon::HurricaneLocalStatement, SignificanceLevel::Statement), - "SPS" => (Phenomenon::SpecialWeatherStatement, SignificanceLevel::Statement), - "SVR" => (Phenomenon::SevereThunderstorm, SignificanceLevel::Warning), - "SVS" => (Phenomenon::SevereWeather, SignificanceLevel::Statement), - "TOR" => (Phenomenon::Tornado, SignificanceLevel::Warning), - - // "flash freeze warning" is Canada-only and not a NWS VTEC code - "FSW" => (Phenomenon::FlashFreeze, SignificanceLevel::Warning), -}; - -/// Database of two-character (plus significance) SAME codes -/// -/// Two-character codes follow a standard convention set by -/// the National Weather Service: the last character is the -/// significance level. -static CODEBOOK2: phf::Map<&'static str, Phenomenon> = phf_map! { - // civil authority codes, two-character with standard significance - "AV" => Phenomenon::Avalanche, - - // weather codes, two-character with standard significance - "BZ" => Phenomenon::Blizzard, - "CF" => Phenomenon::CoastalFlood, - "DS" => Phenomenon::DustStorm, - "EW" => Phenomenon::ExtremeWind, - "FF" => Phenomenon::FlashFlood, - "FL" => Phenomenon::Flood, - "FZ" => Phenomenon::Freeze, - "HU" => Phenomenon::Hurricane, - "HW" => Phenomenon::HighWind, - "SM" => Phenomenon::SpecialMarine, - "SQ" => Phenomenon::SnowSquall, - "SS" => Phenomenon::StormSurge, - "SV" => Phenomenon::SevereThunderstorm, - "TO" => Phenomenon::Tornado, - "TR" => Phenomenon::TropicalStorm, - "TS" => Phenomenon::Tsunami, - "WS" => Phenomenon::WinterStorm, -}; - -/// Get codebook entry for full code like "`RWT`" -fn lookup_threecharacter(code: &str) -> Option { - CODEBOOK3.get(code.get(0..3)?).cloned() -} - -/// Convert `BZx` → `CodeEntry` with proper significance -fn lookup_twocharacter(code: &str) -> Option { - let phenom = CODEBOOK2.get(code.get(0..2)?).cloned()?; - Some((phenom, code.get(2..3)?.into())) -} - -/// Convert `??x` → Unrecognized event with parsed significance -fn lookup_onecharacter(code: &str) -> Option { - Some((Phenomenon::Unrecognized, code.get(2..3)?.into())) -} - -#[cfg(test)] -mod tests { - use super::*; - - use std::collections::HashSet; - - use lazy_static::lazy_static; - use regex::Regex; - use strum::IntoEnumIterator; - - /// ensure we have populated our codebooks correctly - #[test] - fn check_codebooks() { - lazy_static! { - static ref ASCII_UPPER: Regex = Regex::new(r"^[[A-Z]]{2,3}$").expect("bad test regexp"); - } - - let mut codebook_phenomenon = HashSet::new(); - - for (key, val) in CODEBOOK3.entries() { - assert!(key.is_ascii()); - assert_eq!(key.len(), 3); - ASCII_UPPER.is_match(key); - assert_ne!(Phenomenon::Unrecognized, val.0); - assert_ne!(SignificanceLevel::Unknown, val.1); - codebook_phenomenon.insert(val.0); - } - - for (key, val) in CODEBOOK2.entries() { - assert!(key.is_ascii()); - assert_eq!(key.len(), 2); - ASCII_UPPER.is_match(key); - assert_ne!(&Phenomenon::Unrecognized, val); - codebook_phenomenon.insert(*val); - } - - // check that every Phenomenon is covered by at least one codebook entry - for phen in Phenomenon::iter() { - if phen.is_unrecognized() { - continue; - } - - assert!( - codebook_phenomenon.contains(&phen), - "phenomenon {} not covered by any codebook entries", - phen - ); - } - } -} diff --git a/crates/sameold/src/lib.rs b/crates/sameold/src/lib.rs index 81b2bb8..ceaf827 100644 --- a/crates/sameold/src/lib.rs +++ b/crates/sameold/src/lib.rs @@ -92,10 +92,10 @@ //! | 3 | Error correction (bit voting) | //! //! SAME messages are always transmitted three times, in separate "bursts," for -//! redundancy. When decoding the start of message *headers* (`ZCZC`), `samedec` +//! redundancy. When decoding the start of message *headers* (`ZCZC`), `sameold` //! will use all three bursts together to improve decoding—if possible. //! -//! If one retransmission is missed, `samedec` will automatically fall back to +//! If one retransmission is missed, `sameold` will automatically fall back to //! decoding with only two bursts. The decoder imposes a delay of approximately //! **1.311 seconds** on all received headers. This delay is not usually //! problematic as most SAME messages are prefixed with a Warning Alarm Tone that @@ -125,6 +125,11 @@ //! //! ### Interpreting Messages //! +//! > Message decoding and interpretation is provided by the +//! > [`sameplace`](https://docs.rs/sameplace/latest/sameplace/) +//! > crate. `sameold` re-exports the `sameplace::Message` API +//! > for ease-of-use. +//! //! The [`Message`] type marks the start or end of a SAME message. The //! actual "message" part of a SAME message is the audio itself, which //! should contain a voice message that @@ -229,14 +234,15 @@ #![deny(unsafe_code)] #![warn(missing_docs)] -pub mod eventcodes; -mod message; mod receiver; -pub use message::{ +// re-export sameplace's public API +pub use sameplace::eventcodes; +pub use sameplace::{ EventCode, InvalidDateErr, Message, MessageDecodeErr, MessageHeader, MessageResult, Originator, Phenomenon, SignificanceLevel, }; + pub use receiver::{ EqualizerBuilder, LinkState, SameEventType, SameReceiver, SameReceiverBuilder, SameReceiverEvent, TransportState, diff --git a/crates/sameold/src/message.rs b/crates/sameold/src/message.rs deleted file mode 100644 index b948af0..0000000 --- a/crates/sameold/src/message.rs +++ /dev/null @@ -1,915 +0,0 @@ -//! SAME message ASCII encoding and decoding - -mod eventcode; -mod originator; -mod phenomenon; -mod significance; - -use std::convert::TryFrom; -use std::fmt; - -#[cfg(feature = "chrono")] -use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc}; -use lazy_static::lazy_static; -use regex::Regex; -use thiserror::Error; - -pub use eventcode::EventCode; -pub use originator::Originator; -pub use phenomenon::Phenomenon; -pub use significance::SignificanceLevel; - -/// The result of parsing a message -pub type MessageResult = Result; - -/// A fully-decoded SAME/EAS message -/// -/// In the EAS, the "message" is actually the audio signal to be -/// broadcast to the human listener: i.e., the "message" is the -/// synthesized voice you hear on weather radio. The message is -/// wrapped in *audio pass-band* digital data. The digital data -/// demarcates the `StartOfMessage` and the `EndOfMessage`. -/// -/// The `StartOfMessage` contains digital codes and timestamps -/// which summarize the audio message to follow. Some messages -/// are intended for either silent or audible tests. Others -/// report actual emergencies; these either may or must interrupt -/// normal broadcast programming. -/// -/// The audio message immediately follows. The audio message may -/// be up to two minutes long. -/// -/// The `EndOfMessage` demarcates the end of the audio message. -/// -/// `Message` implements `Display` and efficient conversion to -/// `&str`. -/// -/// More information on the SAME/EAS standard may be found in, -/// * "NOAA Weather Radio (NWR) All Hazards Specific Area Message -/// Encoding (SAME)," NWSI 10-172, 3 Oct. 2011, -/// -/// -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub enum Message { - /// Indicates start of audio message - /// - /// A `StartOfMessage` indicates that a SAME/EAS audio - /// message immediately follows. The message - /// [header](MessageHeader) contains the event - /// type, affected areas, time extents, and originator - /// information. - /// - /// For broadcast stations, the in-band audio which immediately - /// follows the `StartOfMessage` *may* break station - /// programming and be aired directly to listeners. - StartOfMessage(MessageHeader), - - /// Indicates end of audio message - /// - /// An `EndOfMessage` marks the conclusion of the SAME/EAS - /// audio message. For broadcast stations, it is an - /// indication that normal programming may resume. - EndOfMessage, -} - -/// Error decoding a `MessageHeader` -#[derive(Error, Clone, Debug, PartialEq, Eq, Hash)] -pub enum MessageDecodeErr { - /// The starting prefix of the message was not recognized - #[error("invalid SAME header: unrecognized prefix")] - UnrecognizedPrefix, - - /// Header contains non-ASCII characters - #[error("invalid SAME header: message contains non-ASCII characters")] - NotAscii, - - /// Header does not match general format - #[error("invalid SAME header: message text does not match required pattern")] - Malformed, -} - -impl Message { - /// Convert to string representation - pub fn as_str(&self) -> &str { - match self { - Self::StartOfMessage(m) => m.as_str(), - Self::EndOfMessage => PREFIX_MESSAGE_END, - } - } - - /// Count of parity errors - /// - /// The number of *bit errors* which were corrected by the - /// 2-of-3 parity correction algorithm. High parity error - /// counts indicate a high bit error rate in the receiving - /// system. - /// - /// Parity errors are *not* tracked for the `EndOfMessage` - /// variant. - pub fn parity_error_count(&self) -> usize { - match self { - Self::StartOfMessage(m) => m.parity_error_count(), - Self::EndOfMessage => 0, - } - } - - /// Number of bytes which were bit-voted - /// - /// `voting_byte_count` is the total number of bytes which were - /// checked via the "two of three" bitwise voting algorithm—i.e., - /// the total number of bytes for which all three SAME bursts were - /// available. - /// - /// Voting counts are *not* tracked for the `EndOfMessage` - /// variant. - pub fn voting_byte_count(&self) -> usize { - match self { - Self::StartOfMessage(m) => m.voting_byte_count(), - Self::EndOfMessage => 0, - } - } -} - -/// An invalid issuance time -#[derive(Error, Clone, Debug, PartialEq, Eq, Hash)] -#[error("message issuance time not valid for its receive time")] -pub struct InvalidDateErr {} - -/// Event, area, time, and originator information -/// -/// The message header is the decoded *digital header* which precedes -/// the analog SAME message. See -/// [crate documentation](./index.html#interpreting-messages) -/// for an example. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct MessageHeader { - // message content, including the leading `ZCZC-` - message: String, - - // where does the time field begin? - // includes the leading plus character (`+`) - offset_time: usize, - - // parity errors - parity_error_count: usize, - - // number of message bytes which could be bit-voted - // (i.e., because three bursts were available) - voting_byte_count: usize, -} - -impl MessageHeader { - /// Try to construct a SAME header from `String` - /// - /// The `message` string must match the general format of - /// a SAME header. If it does not, an error is returned. - pub fn new(message: S) -> Result - where - S: Into, - { - let mut message: String = message.into(); - if !message.is_ascii() { - return Err(MessageDecodeErr::NotAscii); - } - - let (offset_time, hdr_length) = check_header(&message)?; - message.truncate(hdr_length); - - Ok(Self { - message, - offset_time, - parity_error_count: 0, - voting_byte_count: 0, - }) - } - - /// Try to construct a SAME header from `String`, with error counts - /// - /// The `message` string must match the general format of - /// a SAME header. If it does not, an error is returned. - /// - /// The `error_counts` slice counts the number of bit errors - /// corrected in byte of `message`. The slice must have the - /// same length as `message`. - pub fn new_with_errors(message: S, error_counts: &[u8]) -> Result - where - S: Into, - { - let mut out = Self::new(message)?; - let mut parity_error_count = 0; - for (&e, _m) in error_counts.iter().zip(out.message().as_bytes().iter()) { - parity_error_count += e as usize; - } - - out.parity_error_count = parity_error_count; - Ok(out) - } - - /// Try to construct a SAME header from `String`, with error details - /// - /// The `message` string must match the general format of - /// a SAME header. If it does not, an error is returned. - /// - /// The `error_counts` slice counts the number of bit errors - /// corrected in byte of `message`. The slice must have the - /// same byte count as `message`. - /// - /// `burst_counts` is the total number of SAME bursts which were - /// used to estimate each message byte. This slice must have - /// the same byte count as `message`. - pub fn new_with_error_info( - message: S, - error_counts: &[u8], - burst_counts: &[u8], - ) -> Result - where - S: Into, - { - const MIN_BURSTS_FOR_VOTING: u8 = 3; - - let mut out = Self::new_with_errors(message, error_counts)?; - let mut voting_byte_count = 0; - for (&e, _m) in burst_counts.iter().zip(out.message().as_bytes().iter()) { - voting_byte_count += (e >= MIN_BURSTS_FOR_VOTING) as usize; - } - out.voting_byte_count = voting_byte_count; - Ok(out) - } - - /// Message text - /// - /// Returns UTF-8 string representation of a SAME/EAS - /// message. Use the [`release()`](#method.release) - /// method to obtain an owned `String`. - pub fn message(&self) -> &str { - &self.message - } - - /// Message text - /// - /// Returns UTF-8 string representation of a SAME/EAS - /// message. Use the [`release()`](#method.release) - /// method to obtain an owned `String`. - pub fn as_str(&self) -> &str { - &self.message - } - - /// Originator code - /// - /// The ultimate source of the message, such as - /// [`Originator::NationalWeatherService`] for the - /// National Weather Service - pub fn originator(&self) -> Originator { - Originator::from_org_and_call(self.originator_str(), self.callsign()) - } - - /// Originator code (as string) - /// - /// A three-character string that is usually one of the - /// following: - /// - /// - `PEP`: Primary Entry Point Station. Generally only - /// used for national activations, which are very rare. - /// - /// - `CIV`: Civil authorities (usu. state and local government) - /// - /// - `WXR`: National Weather Service or Environment Canada - /// - /// - `EAS`: EAS Participant. Usually a broadcast station. - /// - /// The originator code returned is three characters but is - /// not guaranteed to be one of the above. - pub fn originator_str(&self) -> &str { - &self.message[Self::OFFSET_ORG..Self::OFFSET_ORG + 3] - } - - /// Event code - /// - /// Decodes the SAME event code (like `RWT`) into an - /// [`EventCode`], which is a combination of: - /// - /// * [`phenomenon()`](Phenomenon), which describes what - /// is occurring; and - /// - /// * [`significance()`](SignificanceLevel), which indicates the - /// overall severity and/or how "noisy" or intrusive the alert - /// should be. - /// - /// `EventCode` Display as a human-readable string which describes - /// the SAME code. For example, "`TOR`" displays as "Tornado Warning." - /// - /// ``` - /// # use std::fmt; - /// use sameold::{MessageHeader, Phenomenon, SignificanceLevel}; - /// - /// let msg = MessageHeader::new("ZCZC-WXR-RWT-012345+0351-3662322-NOCALL -").unwrap(); - /// let evt = msg.event(); - /// - /// assert_eq!(evt.phenomenon(), Phenomenon::RequiredWeeklyTest); - /// assert_eq!(evt.significance(), SignificanceLevel::Test); - /// assert_eq!(format!("{}", evt), "Required Weekly Test"); - /// ``` - /// - /// The decoder will make every effort to interpret SAME codes it - /// does not explicitly know. The `EventCode` might contain only a - /// valid significance level—or perhaps not even that. - /// - /// ``` - /// # use std::fmt; - /// # use sameold::{MessageHeader, SignificanceLevel}; - /// let msg = MessageHeader::new("ZCZC-WXR-OMG-012345+0351-3662322-NOCALL -").unwrap(); - /// assert_eq!(msg.event_str(), "OMG"); - /// assert_eq!(msg.event().to_string(), "Unrecognized Warning"); - /// assert_eq!(msg.event().significance(), SignificanceLevel::Unknown); - /// assert!(msg.event().is_unrecognized()); - /// ``` - /// - /// Unrecognized messages are still valid, and clients are encouraged - /// to treat them at their [significance](EventCode::significance) level. - /// Messages where even the significance level cannot be decoded should - /// be treated as Warnings. - /// - /// [`eventcodes`](crate::eventcodes) contains the complete list of SAME - /// codes that are interpreted by `sameold`. See also: [`EventCode`]. - pub fn event(&self) -> EventCode { - EventCode::from(self.event_str()) - } - - /// Event code - /// - /// A three-character code like "`RWT`" which describes the phenomenon - /// and/or the severity level of the message. Use the - /// [`event()`](MessageHeader::event) method to parse this - /// code into its components for further processing or for - /// a human-readable display. - /// - /// See [`eventcodes`](crate::eventcodes) for the complete list - /// of SAME codes that are interpreted by `sameold`. The string value - /// is not guaranteed to be one of these codes. - pub fn event_str(&self) -> &str { - &self.message[Self::OFFSET_EVT..Self::OFFSET_EVT + 3] - } - - /// Iterator over location codes - /// - /// Returns an iterator over the location codes in the - /// message. Location codes are six-digit strings of - /// the form `PSSCCC`: - /// - /// - `P`: part of county, or zero for entire county - /// - `SS`: FIPS State code - /// - `CCC`: FIPS County code - /// - /// Locations are returned in the order listed in the - /// message. Iterator values are guaranteed to be - /// six-digit strings. - /// - /// Per the SAME standard, a message can have up to 31 - /// location codes. - pub fn location_str_iter<'m>(&'m self) -> std::str::Split<'m, char> { - self.location_str().split('-') - } - - /// Message validity duration (Duration) - /// - /// Returns the message validity duration. The message is - /// valid until - /// - /// ```ignore - /// msg.issue_datetime().unwrap() + msg.valid_duration() - /// ``` - /// - /// After this time elapses, the message is no longer valid - /// and should not be relayed or alerted to anymore. - /// - /// This field represents the validity time 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.) - /// - /// The valid duration is relative to the - /// [`issue_datetime()`](#method.issue_datetime) and *not* the - /// current time. - /// - /// Requires `chrono`. - #[cfg(feature = "chrono")] - pub fn valid_duration(&self) -> Duration { - let (hrs, mins) = self.valid_duration_fields(); - Duration::hours(hrs as i64) + Duration::minutes(mins as i64) - } - - /// Message validity duration - /// - /// Returns the message validity duration or "purge time." - /// This is a tuple of (`hours`, `minutes`). - /// - /// This field represents the validity time 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.) - /// - /// The valid duration is relative to the - /// [`issue_daytime_fields()`](#method.issue_daytime_fields). - 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]; - ( - dur_str[0..2].parse().expect(Self::PANIC_MSG), - dur_str[2..4].parse().expect(Self::PANIC_MSG), - ) - } - - /// Estimated message issuance datetime (UTC) - /// - /// Computes the datetime that the SAME message was *issued* - /// from the time that the message was `received`, which - /// must be provided. - /// - /// SAME headers do not include the year of issuance. This makes - /// it impossible to calculate the full datetime of issuance - /// 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. - /// - /// 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. - /// - /// The returned datetime is always in one minute increments - /// with the seconds field set to zero. - /// - /// Requires `chrono`. - #[cfg(feature = "chrono")] - pub fn issue_datetime( - &self, - received: &DateTime, - ) -> Result, InvalidDateErr> { - calculate_issue_time( - self.issue_daytime_fields(), - (received.year(), received.ordinal()), - ) - } - - /// 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. - /// - /// 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. - /// - /// 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, - } - } - - /// Mesage issuance day/time (fields) - /// - /// Returns the message issue day and time, as the string - /// `JJJHHMM`, - /// - /// - `JJJ`: Ordinal day of the year. `001` represents 1 Jan., - /// and `365` represents 31 Dec. in non leap-years. During - /// leap-years, `366` represents 31 Dec. `000` is not used. - /// It is up to the receiving station to have some notion - /// of what the current year is and to detect calendar - /// rollovers. - /// - /// - `HHMM`: UTC time of day, using a 24-hour time scale. - /// Times are UTC and are **NOT** local times. - pub fn issue_daytime_fields(&self) -> (u16, u8, u8) { - let issue = &self.message[self.offset_time + Self::OFFSET_FROMPLUS_ISSUETIME - ..self.offset_time + Self::OFFSET_FROMPLUS_ISSUETIME + 7]; - ( - issue[0..3].parse().expect(Self::PANIC_MSG), - issue[3..5].parse().expect(Self::PANIC_MSG), - issue[5..7].parse().expect(Self::PANIC_MSG), - ) - } - - /// Sending station callsign - /// - /// The FCC or other regulatory body-assigned callsign - /// of the sending station. Minus signs (`-`) in the - /// callsign are replaced with slashes (`/`). - pub fn callsign(&self) -> &str { - let end = self.message.len(); - &self.message[self.offset_time + Self::OFFSET_FROMPLUS_CALLSIGN - ..end - Self::OFFSET_FROMEND_CALLSIGN_END] - } - - /// Count of parity errors - /// - /// The number of *bit errors* which were corrected by the - /// 2-of-3 parity correction algorithm. High parity error - /// counts indicate a high bit error rate in the receiving - /// system. - pub fn parity_error_count(&self) -> usize { - self.parity_error_count - } - - /// Number of bytes which were bit-voted - /// - /// `voting_byte_count` is the total number of bytes which were - /// checked via the "two of three" bitwise voting algorithm—i.e., - /// the total number of bytes for which all three SAME bursts were - /// available. - pub fn voting_byte_count(&self) -> usize { - self.voting_byte_count - } - - /// True if the message is a national activation - /// - /// Returns true if: - /// - /// - the location code in the SAME message indicates - /// national applicability; and - /// - /// - the event code is reserved for national use - /// - /// The message may either be a test or an actual emergency. - /// Consult the [`event()`](MessageHeader::event) for details. - /// - /// Clients are **strongly encouraged** to always play - /// national-level messages and to never provide the option to - /// suppress them. - pub fn is_national(&self) -> bool { - self.location_str() == Self::LOCATION_NATIONAL && self.event().phenomenon().is_national() - } - - /// Obtain the owned message String - /// - /// Destroys this object and releases the message - /// contained within - pub fn release(self) -> String { - self.message - } - - /// The location portion of the message string - fn location_str(&self) -> &str { - &self.message[Self::OFFSET_AREA_START..self.offset_time] - } - - const OFFSET_ORG: usize = 5; - const OFFSET_EVT: usize = 9; - const OFFSET_AREA_START: usize = 13; - const OFFSET_FROMPLUS_VALIDTIME: usize = 1; - const OFFSET_FROMPLUS_ISSUETIME: usize = 6; - const OFFSET_FROMPLUS_CALLSIGN: usize = 14; - const OFFSET_FROMEND_CALLSIGN_END: usize = 1; - const PANIC_MSG: &'static str = "MessageHeader validity check admitted a malformed message"; - const LOCATION_NATIONAL: &'static str = "000000"; -} - -impl fmt::Display for Message { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.as_str().fmt(f) - } -} - -impl AsRef for Message { - #[inline] - fn as_ref(&self) -> &str { - self.as_str() - } -} - -impl TryFrom for Message { - type Error = MessageDecodeErr; - - #[inline] - fn try_from(inp: String) -> Result { - if inp.starts_with(PREFIX_MESSAGE_START) { - Ok(Message::StartOfMessage(MessageHeader::try_from(inp)?)) - } else if inp.starts_with(&PREFIX_MESSAGE_END[0..2]) { - Ok(Message::EndOfMessage) - } else { - Err(MessageDecodeErr::UnrecognizedPrefix) - } - } -} - -impl TryFrom<(String, &[u8])> for Message { - type Error = MessageDecodeErr; - - #[inline] - fn try_from(inp: (String, &[u8])) -> Result { - if inp.0.starts_with(PREFIX_MESSAGE_START) { - Ok(Message::StartOfMessage(MessageHeader::try_from(inp)?)) - } else if inp.0.starts_with(&PREFIX_MESSAGE_END[0..2]) { - Ok(Message::EndOfMessage) - } else { - Err(MessageDecodeErr::UnrecognizedPrefix) - } - } -} - -impl TryFrom<(&[u8], &[u8], &[u8])> for Message { - type Error = MessageDecodeErr; - - #[inline] - fn try_from(inp: (&[u8], &[u8], &[u8])) -> Result { - let instr = std::str::from_utf8(inp.0).map_err(|_e| MessageDecodeErr::NotAscii)?; - if instr.starts_with(PREFIX_MESSAGE_START) { - Ok(Message::StartOfMessage(MessageHeader::try_from(( - instr.to_owned(), - inp.1, - inp.2, - ))?)) - } else if instr.starts_with(&PREFIX_MESSAGE_END[0..2]) { - Ok(Message::EndOfMessage) - } else { - Err(MessageDecodeErr::UnrecognizedPrefix) - } - } -} - -impl fmt::Display for MessageHeader { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.message.fmt(f) - } -} - -impl AsRef for MessageHeader { - #[inline] - fn as_ref(&self) -> &str { - self.message() - } -} - -impl AsRef<[u8]> for MessageHeader { - #[inline] - fn as_ref(&self) -> &[u8] { - self.message().as_bytes() - } -} - -impl From for String { - #[inline] - fn from(msg: MessageHeader) -> String { - msg.release() - } -} - -impl TryFrom for MessageHeader { - type Error = MessageDecodeErr; - - #[inline] - fn try_from(inp: String) -> Result { - Self::new(inp) - } -} - -impl TryFrom<(String, &[u8])> for MessageHeader { - type Error = MessageDecodeErr; - - #[inline] - fn try_from(inp: (String, &[u8])) -> Result { - Self::new_with_errors(inp.0, inp.1) - } -} - -impl TryFrom<(String, &[u8], &[u8])> for MessageHeader { - type Error = MessageDecodeErr; - - #[inline] - fn try_from(inp: (String, &[u8], &[u8])) -> Result { - Self::new_with_error_info(inp.0, inp.1, inp.2) - } -} - -const PREFIX_MESSAGE_START: &str = "ZCZC-"; -const PREFIX_MESSAGE_END: &str = "NNNN"; - -// Check message header for basic format compliance -// -// We validate that the message may be split into fields -// correctly, but we do *not* do much validation of the -// fields themselves. Returns tuple of -// -// 1. start position of the purge time field, following -// the `+`. -// 2. total length of the header. The `hdr` may be longer. -fn check_header(hdr: &str) -> Result<(usize, usize), MessageDecodeErr> { - lazy_static! { - static ref RE: Regex = Regex::new( - r"^ZCZC-[[:alpha:]]{3}-[[:alpha:]]{3}(-[0-9]{6})+(\+[0-9]{4}-[0-9]{7}-.{3,8}-)" - ) - .expect("bad SAME regexp"); - } - - let mtc = RE - .captures(hdr) - .ok_or(MessageDecodeErr::Malformed)? - .get(2) - .ok_or(MessageDecodeErr::Malformed)?; - - Ok((mtc.start(), mtc.end())) -} - -// Calculate message issuance time -// -// Calculate Utc datetime of message issuance from the -// fields encoded into the `message` and a local estimate -// of when the message was `received`. -#[cfg(feature = "chrono")] -fn calculate_issue_time( - message: (u16, u8, u8), - received: (i32, u32), -) -> Result, InvalidDateErr> { - let (day_of_year, hour, minute) = message; - let (rx_year, rx_day_of_year) = received; - - let daydiff = rx_day_of_year as i32 - day_of_year as i32; - let msg_year = if daydiff >= 180 { - // message is over 180 days from now, which is unlikely - // what is more likely is that the UTC new year has - // arrived and this message is from next year - rx_year.saturating_add(1) - } else if daydiff <= -180 { - // message is over 180 days old, which is unlikely - // what is more likely is that we have received - // a message from last UTC year - rx_year.saturating_sub(1) - } else { - // message was received in the current year - rx_year - }; - - // construct a calendar date - yo_hms_to_utc(msg_year, day_of_year as u32, hour as u32, minute as u32, 0) - .ok_or(InvalidDateErr {}) -} - -// Create the latest-possible Utc date from year, ordinal, and HMS -#[cfg(feature = "chrono")] -#[inline] -fn yo_hms_to_utc( - year: i32, - ordinal: u32, - hour: u32, - minute: u32, - second: u32, -) -> Option> { - Some(Utc.from_utc_datetime( - &NaiveDate::from_yo_opt(year, ordinal)?.and_hms_opt(hour, minute, second)?, - )) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[cfg(feature = "chrono")] - use chrono::{TimeZone, Utc}; - - #[test] - fn test_check_header() { - const INVALID_SHORT: &str = "ZCZC-ORG-EEE-+0000-0001122-NOCALL00-"; - const VALID_ONE: &str = "ZCZC-ORG-EEE-012345+0000-0001122-NOCALL00-"; - const VALID_TWO: &str = "ZCZC-ORG-EEE-012345-567890+0000-0001122-NOCALL00-garbage"; - - assert_eq!( - Err(MessageDecodeErr::Malformed), - check_header(INVALID_SHORT) - ); - - assert_eq!(Ok((19, 42)), check_header(VALID_ONE)); - assert_eq!(VALID_ONE.as_bytes()[19], '+' as u8); - - assert_eq!(Ok((26, 49)), check_header(VALID_TWO)); - assert_eq!(VALID_TWO.as_bytes()[26], '+' as u8); - } - - #[test] - #[cfg(feature = "chrono")] - fn test_calculate_issue_time() { - let d = calculate_issue_time((83, 2, 53), (2021, 1)).unwrap(); - assert_eq!(d, Utc.with_ymd_and_hms(2021, 3, 24, 2, 53, 0).unwrap()); - - let d = calculate_issue_time((84, 23, 59), (2021, 1)).unwrap(); - assert_eq!(d, Utc.with_ymd_and_hms(2021, 3, 25, 23, 59, 0).unwrap()); - - // close to the current year - let d = calculate_issue_time((1, 10, 00), (2021, 1)).unwrap(); - assert_eq!(d, Utc.with_ymd_and_hms(2021, 1, 1, 10, 00, 0).unwrap()); - - // bumps to next year - let d = calculate_issue_time((1, 10, 00), (2021, 200)).unwrap(); - assert_eq!(d, Utc.with_ymd_and_hms(2022, 1, 1, 10, 00, 0).unwrap()); - - // this too - let d = calculate_issue_time((1, 10, 00), (2021, 365)).unwrap(); - assert_eq!(d, Utc.with_ymd_and_hms(2022, 1, 1, 10, 00, 0).unwrap()); - - // reverts to previous year, with leap year support - let d = calculate_issue_time((366, 10, 00), (2021, 1)).unwrap(); - assert_eq!(d, Utc.with_ymd_and_hms(2020, 12, 31, 10, 00, 0).unwrap()); - - // but this doesn't work at all if the year we propagate into - // is not a leap year - calculate_issue_time((366, 10, 00), (1971, 364)).expect_err("should not succeed"); - - // and ordinal day 0 is totally invalid - calculate_issue_time((0, 10, 00), (1971, 364)).expect_err("should not succeed"); - - // hours invalid - calculate_issue_time((84, 25, 59), (2021, 84)).expect_err("should not succeed"); - } - - #[test] - fn test_message_header() { - const THREE_LOCATIONS: &str = "ZCZC-WXR-RWT-012345-567890-888990+0351-3662322-NOCALL00-@@@"; - - let mut errs = vec![0u8; THREE_LOCATIONS.len()]; - errs[0] = 1u8; - errs[20] = 5u8; - errs[THREE_LOCATIONS.len() - 1] = 8u8; - - let burst_count = vec![3u8; THREE_LOCATIONS.len()]; - - let msg = MessageHeader::try_from(( - THREE_LOCATIONS.to_owned(), - errs.as_slice(), - burst_count.as_slice(), - )) - .expect("bad msg"); - - assert_eq!(msg.originator_str(), "WXR"); - 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.issue_daytime_fields(), (366, 23, 22)); - assert_eq!(msg.callsign(), "NOCALL00"); - assert_eq!(msg.parity_error_count(), 6); - assert_eq!(msg.voting_byte_count(), msg.as_str().len()); - assert!(!msg.is_national()); - - let loc: Vec<&str> = msg.location_str_iter().collect(); - assert_eq!(loc.as_slice(), &["012345", "567890", "888990"]); - - // time API checks - #[cfg(feature = "chrono")] - { - 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() - ); - assert_eq!( - msg.valid_duration(), - Duration::hours(3) + Duration::minutes(51) - ); - 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())); - } - - // try again via Message - let msg = Message::try_from(THREE_LOCATIONS.to_owned()).expect("bad msg"); - match &msg { - Message::StartOfMessage(m) => assert_eq!(m.issue_daytime_fields(), (366, 23, 22)), - _ => unreachable!(), - } - assert_eq!(&THREE_LOCATIONS[0..56], &format!("{}", msg)); - } - - #[test] - fn test_message() { - let msg = Message::try_from("NNNN".to_owned()).expect("bad msg"); - assert_eq!(Message::EndOfMessage, msg); - assert_eq!("NNNN", &format!("{}", msg)); - - let msg = Message::try_from("NN".to_owned()).expect("bad msg"); - assert_eq!(Message::EndOfMessage, msg); - } - - #[test] - fn test_is_national() { - let national = MessageHeader::new("ZCZC-PEP-NPT-000000+0030-2771820-TEST -").unwrap(); - assert!(national.is_national()); - - let national = MessageHeader::new("ZCZC-PEP-EAN-000000+0030-2771820-TEST -").unwrap(); - assert!(national.is_national()); - - let not_national = - MessageHeader::new("ZCZC-PEP-NPT-000001+0030-2771820-TEST -").unwrap(); - assert!(!not_national.is_national()); - - let not_national = - MessageHeader::new("ZCZC-PEP-NPT-000000-000001+0030-2771820-TEST -").unwrap(); - assert!(!not_national.is_national()); - } -} diff --git a/crates/sameold/src/message/eventcode.rs b/crates/sameold/src/message/eventcode.rs deleted file mode 100644 index e72357d..0000000 --- a/crates/sameold/src/message/eventcode.rs +++ /dev/null @@ -1,317 +0,0 @@ -//! Event decoding and representation - -use std::fmt; - -use crate::eventcodes::{parse_event, CodeEntry}; - -use super::phenomenon::Phenomenon; -use super::significance::SignificanceLevel; - -/// Decoded SAME event code -/// -/// Represents the decoding of a three-character SAME event code, -/// like "`RWT`," into a [phenomenon](EventCode::phenomenon) and -/// [significance](EventCode::significance). -/// -/// * The phenomenon describes what is occurring -/// -/// * The significance indicates the overall severity and/or how -/// "noisy" or intrusive the alert should be. -/// -/// EventCode are usually constructed via -/// [`MessageHeader::event()`](crate::MessageHeader::event) but may also -/// be directly created from string. -/// -/// ``` -/// use sameold::{EventCode, Phenomenon, SignificanceLevel}; -/// -/// let evt = EventCode::from("RWT"); -/// assert_eq!(evt.phenomenon(), Phenomenon::RequiredWeeklyTest); -/// assert_eq!(evt.significance(), SignificanceLevel::Test); -/// ``` -/// -/// EventCode are `Ord` by their significance levels. -/// -/// ``` -/// # use sameold::EventCode; -/// assert!(EventCode::from("RWT") < EventCode::from("SVA")); -/// assert!(EventCode::from("SVA") < EventCode::from("SVR")); -/// ``` -/// -/// The `Display` representation is a human-readable string representing -/// both phenomenon and significance. -/// -/// ``` -/// # use sameold::EventCode; -/// # use std::fmt; -/// assert_eq!(EventCode::from("SVA").to_string(), "Severe Thunderstorm Watch"); -/// ``` -/// -/// The conversion from string is infallible, but invalid strings will -/// result in an [unrecognized](EventCode::is_unrecognized) message. -/// -/// ``` -/// # use sameold::{EventCode, SignificanceLevel}; -/// let watch = EventCode::from("??A"); -/// assert!(watch.is_unrecognized()); -/// assert_eq!(watch.significance(), SignificanceLevel::Watch); -/// assert_eq!(watch.to_string(), "Unrecognized Watch"); -/// -/// let unrec = EventCode::from("???"); -/// assert!(unrec.is_unrecognized()); -/// assert_eq!(unrec.significance(), SignificanceLevel::Unknown); -/// assert_eq!(unrec.to_string(), "Unrecognized Warning"); -/// ``` -/// -/// If the phenomenon portion cannot be decoded, the third character -/// is parsed as a `SignificanceLevel` if possible. Unrecognized messages -/// are still valid, and clients are encouraged to handle them at their -/// [significance level](EventCode::significance) as normal. -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] -pub struct EventCode { - phenomenon: Phenomenon, - significance: SignificanceLevel, -} - -impl EventCode { - /// Parse from SAME code, like "`RWT`" - /// - /// This type is usually constructed via - /// [`MessageHeader::event()`](crate::MessageHeader::event), but - /// you can also construct them directly. This method decodes the - /// string representation of a three-character SAME event `code`, - /// like "`RWT`," into a machine-readable event. - /// - /// If the input `code` is not known to `sameold`, is not in the - /// required format (i.e., three ASCII characters), or is otherwise - /// not valid, the output of - /// [`is_unrecognized()`](EventCode::is_unrecognized) will be - /// `true`. - pub fn from(code: S) -> Self - where - S: AsRef, - { - parse_event(code).unwrap_or_default().into() - } - - /// What is occurring? - pub fn phenomenon(&self) -> Phenomenon { - self.phenomenon - } - - /// What is the anticipated severity? - pub fn significance(&self) -> SignificanceLevel { - self.significance - } - - /// Human-readable string representation - /// - /// Converts to a human-readable string, like "`Required Monthly Test`." - pub fn to_display_string(&self) -> String { - self.to_string() - } - - /// True for test messages - /// - /// Test messages do not represent real-life events or emergencies. - pub fn is_test(&self) -> bool { - self.significance() == SignificanceLevel::Test || self.phenomenon().is_test() - } - - /// True if any part of the event code was unrecognized - /// - /// Indicates that either the phenomenon or the significance - /// could not be determined from the input SAME code. - /// - /// Unrecognized messages are still valid, and clients are - /// encouraged to handle them at their - /// [significance level](EventCode::significance) as normal. - pub fn is_unrecognized(&self) -> bool { - self.phenomenon == Phenomenon::Unrecognized - || self.significance == SignificanceLevel::Unknown - } - - /// An unrecognized event code - pub(crate) const fn unrecognized() -> Self { - Self { - phenomenon: Phenomenon::Unrecognized, - significance: SignificanceLevel::Unknown, - } - } -} - -impl Default for EventCode { - fn default() -> Self { - Self::unrecognized() - } -} - -impl From<&str> for EventCode { - fn from(value: &str) -> Self { - EventCode::from(value) - } -} - -impl From<&String> for EventCode { - fn from(value: &String) -> Self { - EventCode::from(value) - } -} - -impl fmt::Display for EventCode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if f.alternate() { - self.phenomenon().fmt(f) - } else { - let phenom_pattern = self.phenomenon().as_full_pattern_str(); - if let Some(phenom_need_sig) = phenom_pattern.strip_suffix("%") { - // pattern string needs significance - write!(f, "{}{}", phenom_need_sig, self.significance) - } else { - // pattern string is complete - phenom_pattern.fmt(f) - } - } - } -} - -impl Ord for EventCode { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.significance().cmp(&other.significance()) - } -} - -impl PartialOrd for EventCode { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl From for EventCode { - fn from(value: CodeEntry) -> Self { - Self { - phenomenon: value.0, - significance: value.1, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_unrecognized() { - assert_eq!(EventCode::from(""), EventCode::default()); - assert_eq!(EventCode::default(), EventCode::unrecognized()); - } - - #[test] - fn basic_parsing() { - let unk = EventCode::from(""); - assert_eq!(unk, EventCode::default()); - assert_eq!(Phenomenon::Unrecognized, unk.phenomenon()); - assert_eq!(SignificanceLevel::Unknown, unk.significance()); - - let code_tor = EventCode::from("TOR"); - assert_eq!(Phenomenon::Tornado, code_tor.phenomenon()); - assert_eq!(SignificanceLevel::Warning, code_tor.significance()); - - let code_toe = EventCode::from("TOE"); - assert_eq!(Phenomenon::TelephoneOutage, code_toe.phenomenon()); - assert_eq!(SignificanceLevel::Emergency, code_toe.significance()); - - let code_toa = EventCode::from("TOA"); - assert_eq!(Phenomenon::Tornado, code_toa.phenomenon()); - assert_eq!(SignificanceLevel::Watch, code_toa.significance()); - - // this is **NOT** a valid SAME code… but since the `TO` - // prefix is also used for two-character decoding. this still - // works - let code_tow = EventCode::from("TOW"); - assert_eq!(Phenomenon::Tornado, code_tow.phenomenon()); - assert_eq!(SignificanceLevel::Warning, code_tow.significance()); - - // four-character codes do not fly - assert_eq!(EventCode::from("TORZ"), EventCode::default()); - - // this code is unknown, but we can still extract a significance - let code_dew = EventCode::from("DEW"); - assert_eq!(Phenomenon::Unrecognized, code_dew.phenomenon()); - assert_eq!(SignificanceLevel::Warning, code_dew.significance()); - - // an unknown significance on a two-character code - let code_bz = EventCode::from("BZ!"); - assert_eq!(Phenomenon::Blizzard, code_bz.phenomenon()); - assert_eq!(SignificanceLevel::Unknown, code_bz.significance()); - } - - #[test] - fn basic_display() { - let evt = EventCode::from("EAN"); - assert_eq!("National Emergency Message", evt.to_string()); - - // three-character codes sometimes bake in their significance - let evt = EventCode::from("TOR"); - assert_eq!("Tornado Warning", evt.to_string()); - - // two-character codes must populate - let evt = EventCode::from("BZW"); - assert_eq!("Blizzard Warning", evt.to_string()); - - // not really SAME, but it decodes. - let evt = EventCode::from("BZS"); - assert_eq!("Blizzard Statement", evt.to_string()); - - // alternate format describes event w/o significance - let evt = EventCode::from("TOE"); - assert_eq!("911 Telephone Outage", &format!("{:#}", evt)); - assert_eq!("911 Telephone Outage Emergency", &format!("{}", evt)); - - let evt = EventCode::from("EVI"); - assert_eq!("Evacuation", &format!("{:#}", evt)); - assert_eq!("Evacuation Immediate", &format!("{}", evt)); - - // we still have a display for completely unknown codes - let evt = EventCode::from("!!!"); - assert_eq!("Unrecognized Warning", &format!("{}", evt)); - assert_eq!("Unrecognized", &format!("{:#}", evt)); - } - - #[test] - fn test_support_required_codes() { - // Event codes from crate::eventcodes docstring - const TEST_CODES: &[&str] = &[ - "ADR", "AVA", "AVW", "BLU", "BZW", "CAE", "CDW", "CEM", "CFA", "CFW", "DMO", "DSW", - "EAN", "EQW", "EVI", "EWW", "FFA", "FFS", "FFW", "FLA", "FLS", "FLW", "FRW", "FSW", - "FZW", "HLS", "HMW", "HUA", "HUW", "HWA", "HWW", "LAE", "LEW", "NAT", "NIC", "NMN", - "NPT", "NST", "NUW", "RHW", "RMT", "RWT", "SMW", "SPS", "SPW", "SQW", "SSA", "SSW", - "SVA", "SVR", "SVS", "TOA", "TOE", "TOR", "TRA", "TRW", "TSA", "TSW", "VOW", "WSA", - "WSW", - ]; - - for code in TEST_CODES.iter().cloned() { - let evt = EventCode::from(code); - assert!( - evt.phenomenon().is_recognized(), - "event code {} was not recognized", - code - ); - - assert!( - evt.significance() != SignificanceLevel::Unknown, - "event code {} has unknown significance; must be known", - code - ); - - // ensure display does not contain format code - let disp = format!("{}", evt); - assert!(!disp.contains("%")); - - // all test messages should have a significance of test - if evt.phenomenon().is_test() { - assert_eq!(evt.significance(), SignificanceLevel::Test); - } - } - } -} diff --git a/crates/sameold/src/message/originator.rs b/crates/sameold/src/message/originator.rs deleted file mode 100644 index c9a0427..0000000 --- a/crates/sameold/src/message/originator.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! Originator code - -use std::fmt; - -use strum::EnumMessage; - -/// SAME message originator code -/// -/// See [`MessageHeader::originator()`](crate::MessageHeader::originator). -/// Originator codes may be also parsed from the SAME -/// [org code and callsign](Originator::from_org_and_call): -/// -/// ``` -/// use sameold::Originator; -/// -/// let orig = Originator::from_org_and_call("WXR", "KLOX/NWS"); -/// assert_eq!(Originator::NationalWeatherService, orig); -/// -/// // other originators -/// assert_eq!(Originator::Unknown, Originator::from_org_and_call("HUH", "")); -/// assert_eq!("CIV", Originator::CivilAuthority.as_code_str()); -/// ``` -/// -/// Originators Display a human-readable string: -/// -/// ``` -/// # use sameold::Originator; -/// # let orig = Originator::from_org_and_call("WXR", "KLOX/NWS"); -/// assert_eq!("National Weather Service", orig.as_display_str()); -/// assert_eq!("National Weather Service", &format!("{}", orig)); -/// assert_eq!("WXR", orig.as_ref()); -/// assert_eq!("WXR", &format!("{:#}", orig)); -/// ``` -/// -/// The callsign is required to reliably detect the National Weather Service -/// and/or Environment Canada: -/// -/// ``` -/// # use sameold::Originator; -/// assert_eq!(Originator::EnvironmentCanada, -/// Originator::from_org_and_call("WXR", "EC/GC/CA")); -/// assert_eq!("WXR", Originator::EnvironmentCanada.as_code_str()); -/// ``` -#[derive( - Clone, Copy, Debug, PartialEq, Eq, Hash, strum_macros::EnumMessage, strum_macros::EnumString, -)] -pub enum Originator { - /// An unknown (and probably invalid) Originator code - /// - /// Per NWSI 10-172, receivers should accept any originator code. - #[strum(serialize = "", detailed_message = "Unknown Originator")] - Unknown, - - /// Primary Entry Point station for national activations - /// - /// Nation-wide activations are authorized by the President of - /// the United States. Takes priority over all other - /// messages/station programming. - #[strum(serialize = "PEP", detailed_message = "Primary Entry Point System")] - PrimaryEntryPoint, - - /// Civil authorities - #[strum(serialize = "CIV", detailed_message = "Civil authorities")] - CivilAuthority, - - /// National Weather Service - #[strum(serialize = "WXR", detailed_message = "National Weather Service")] - NationalWeatherService, - - /// Environment Canada - /// - /// In Canada, SAME is only transmitted on the Weatheradio Canada - /// radio network to alert weather radios. SAME signals are not - /// transmitted on broadcast AM/FM or cable systems. - /// - /// This enum variant will only be selected if the sending station's - /// callsign matches the format of Environment Canada stations. - #[strum(message = "WXR", detailed_message = "Environment Canada")] - EnvironmentCanada, - - /// EAS participant (usu. broadcast station) - #[strum( - serialize = "EAS", - detailed_message = "Broadcast station or cable system" - )] - BroadcastStation, -} - -impl Originator { - /// Construct from originator string and station callsign - pub fn from_org_and_call(org: S1, call: S2) -> Self - where - S1: AsRef, - S2: AsRef, - { - let decode = str::parse(org.as_ref()).unwrap_or_default(); - if decode == Self::NationalWeatherService && call.as_ref().starts_with("EC/") { - Self::EnvironmentCanada - } else { - decode - } - } - - /// Human-readable string representation - /// - /// Converts to a human-readable string, like "`Civil authorities`." - pub fn as_display_str(&self) -> &'static str { - self.get_detailed_message().expect("missing definition") - } - - /// SAME string representation - /// - /// Returns the SAME code for this `Originator`. - /// [`Originator::Unknown`] returns the empty string. - pub fn as_code_str(&self) -> &'static str { - self.get_message() - .unwrap_or_else(|| self.get_serializations()[0]) - } -} - -impl std::default::Default for Originator { - fn default() -> Self { - Self::Unknown - } -} - -impl AsRef for Originator { - fn as_ref(&self) -> &'static str { - self.as_code_str() - } -} - -impl fmt::Display for Originator { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if f.alternate() { - self.as_code_str().fmt(f) - } else { - self.as_display_str().fmt(f) - } - } -} diff --git a/crates/sameold/src/message/phenomenon.rs b/crates/sameold/src/message/phenomenon.rs deleted file mode 100644 index a078936..0000000 --- a/crates/sameold/src/message/phenomenon.rs +++ /dev/null @@ -1,530 +0,0 @@ -//! SAME/EAS Event Codes - -use std::fmt; - -use strum::{EnumMessage, EnumProperty}; - -/// SAME message phenomenon -/// -/// A Phenomenon code indicates what prompted the message. These include -/// tests, such as the -/// [required weekly test](Phenomenon::RequiredWeeklyTest), -/// and live messages like [floods](Phenomenon::Flood). Some events -/// have multiple significance levels: floods can be reported as both -/// a "Flood Watch" and a "Flood Warning." The `Phenomenon` only encodes -/// `Phenomenon::Flood`—the [significance](crate::SignificanceLevel) -/// is left to other types. -/// -/// Phenomenon may be matched individually if the user wishes to take -/// special action… -/// -/// ``` -/// # use sameold::Phenomenon; -/// # let phenomenon = Phenomenon::Flood; -/// match phenomenon { -/// Phenomenon::Flood => println!("this message describes a flood"), -/// _ => { /* pass */ } -/// } -/// ``` -/// -/// … but the programmer must **exercise caution** here. Flooding may also -/// result from a [`Phenomenon::FlashFlood`] or a larger event like a -/// [`Phenomenon::Hurricane`]. An evacuation might be declared with -/// [`Phenomenon::Evacuation`], but many other messages might prompt an -/// evacuation as part of the response. So: -/// -/// **⚠️ When in doubt, play the message and let the user decide! ⚠️** -/// -/// sameold *does* separate Phenomenon into broad categories. These include: -/// -/// ``` -/// # use sameold::Phenomenon; -/// assert!(Phenomenon::NationalPeriodicTest.is_national()); -/// assert!(Phenomenon::NationalPeriodicTest.is_test()); -/// assert!(Phenomenon::SevereThunderstorm.is_weather()); -/// assert!(Phenomenon::Fire.is_non_weather()); -/// ``` -/// -/// All Phenomenon `Display` a human-readable description of the event, -/// without its significance level. -/// -/// ``` -/// # use sameold::Phenomenon; -/// use std::fmt; -/// -/// assert_eq!(format!("{}", Phenomenon::HazardousMaterials), "Hazardous Materials"); -/// assert_eq!(Phenomenon::HazardousMaterials.as_brief_str(), "Hazardous Materials"); -/// ``` -/// -/// but you probably want to display the full -/// [`EventCode`](crate::EventCode) instead. -/// -/// NOTE: the strum traits on this type are **not** considered API. -#[derive( - Clone, - Copy, - Debug, - PartialEq, - Eq, - Hash, - strum_macros::EnumMessage, - strum_macros::EnumProperty, - strum_macros::EnumIter, -)] -#[non_exhaustive] -pub enum Phenomenon { - /// National Emergency Message - /// - /// This was previously known as Emergency Action Notification - #[strum( - message = "National Emergency", - detailed_message = "National Emergency Message", - props(national = "") - )] - NationalEmergency, - - /// National Information Center (United States, part of national activation) - #[strum(message = "National Information Center", props(national = ""))] - NationalInformationCenter, - - /// National Audible Test (Canada) - #[strum(message = "National Audible Test", props(national = "", test = ""))] - NationalAudibleTest, - - /// National Periodic Test (United States) - #[strum(message = "National Periodic Test", props(national = "", test = ""))] - NationalPeriodicTest, - - /// National Silent Test (Canada) - #[strum(message = "National Silent Test", props(national = "", test = ""))] - NationalSilentTest, - - /// Required Monthly Test - #[strum(message = "Required Monthly Test", props(test = ""))] - RequiredMonthlyTest, - - /// Required Weekly Test - #[strum(message = "Required Weekly Test", props(test = ""))] - RequiredWeeklyTest, - - /// Administrative Message - /// - /// Used as follow-up for non-weather messages, including potentially - /// to issue an all-clear. - #[strum(message = "Administrative Message")] - AdministrativeMessage, - - /// Avalanche - #[strum(message = "Avalanche", detailed_message = "Avalanche %")] - Avalanche, - - /// Blizzard - #[strum( - message = "Blizzard", - detailed_message = "Blizzard %", - props(weather = "") - )] - Blizzard, - - /// Blue Alert (state/local) - #[strum(message = "Blue Alert")] - BlueAlert, - - /// Child Abduction Emergency (state/local) - #[strum( - message = "Child Abduction", - detailed_message = "Child Abduction Emergency" - )] - ChildAbduction, - - /// Civil Danger Warning (state/local) - #[strum(message = "Civil Danger", detailed_message = "Civil Danger Warning")] - CivilDanger, - - /// Civil Emergency Message (state/local) - #[strum( - message = "Civil Emergency", - detailed_message = "Civil Emergency Message" - )] - CivilEmergency, - - /// Coastal Flood - #[strum( - message = "Coastal Flood", - detailed_message = "Coastal Flood %", - props(weather = "") - )] - CoastalFlood, - - /// Dust Storm - #[strum( - message = "Dust Storm", - detailed_message = "Dust Storm %", - props(weather = "") - )] - DustStorm, - - /// Earthquake Warning - /// - /// **NOTE:** It is unclear if SAME is fast enough to provide timely - /// notifications of earthquakes. - #[strum(message = "Earthquake", detailed_message = "Earthquake Warning")] - Earthquake, - - /// Evacuation Immediate - #[strum(message = "Evacuation", detailed_message = "Evacuation Immediate")] - Evacuation, - - /// Extreme Wind - #[strum( - message = "Extreme Wind", - detailed_message = "Extreme Wind %", - props(weather = "") - )] - ExtremeWind, - - /// Fire Warning - #[strum(message = "Fire", detailed_message = "Fire %")] - Fire, - - /// Flash Flood - #[strum( - message = "Flash Flood", - detailed_message = "Flash Flood %", - props(weather = "") - )] - FlashFlood, - - /// Flash Freeze (Canada) - #[strum( - message = "Flash Freeze", - detailed_message = "Flash Freeze %", - props(weather = "") - )] - FlashFreeze, - - /// Flood - #[strum(message = "Flood", detailed_message = "Flood %", props(weather = ""))] - Flood, - - /// Freeze (Canada) - #[strum(message = "Freeze", detailed_message = "Freeze %", props(weather = ""))] - Freeze, - - /// Hazardous Materials (Warning) - #[strum( - message = "Hazardous Materials", - detailed_message = "Hazardous Materials Warning" - )] - HazardousMaterials, - - /// High Wind - #[strum( - message = "High Wind", - detailed_message = "High Wind %", - props(weather = "") - )] - HighWind, - - /// Hurricane - #[strum( - message = "Hurricane", - detailed_message = "Hurricane %", - props(weather = "") - )] - Hurricane, - - /// Hurricane Local Statement - #[strum(message = "Hurricane Local Statement", props(weather = ""))] - HurricaneLocalStatement, - - /// Law Enforcement Warning - #[strum(message = "Law Enforcement Warning")] - LawEnforcementWarning, - - /// Local Area Emergency - #[strum(message = "Local Area Emergency")] - LocalAreaEmergency, - - /// Network Message Notification - #[strum(message = "Network Message Notification")] - NetworkMessageNotification, - - /// 911 Telephone Outage Emergency - #[strum( - message = "911 Telephone Outage", - detailed_message = "911 Telephone Outage Emergency" - )] - TelephoneOutage, - - /// Nuclear Power Plant (Warning) - #[strum( - message = "Nuclear Power Plant", - detailed_message = "Nuclear Power Plant Warning" - )] - NuclearPowerPlant, - - /// Practice/Demo Warning - #[strum(message = "Practice/Demo Warning")] - PracticeDemoWarning, - - /// Radiological Hazard - #[strum( - message = "Radiological Hazard", - detailed_message = "Radiological Hazard Warning" - )] - RadiologicalHazard, - - /// Severe Thunderstorm - #[strum( - message = "Severe Thunderstorm", - detailed_message = "Severe Thunderstorm %", - props(weather = "") - )] - SevereThunderstorm, - - /// Severe Weather Statement - #[strum( - message = "Severe Weather", - detailed_message = "Severe Weather %", - props(weather = "") - )] - SevereWeather, - - /// Shelter In Place - #[strum( - message = "Shelter In Place", - detailed_message = "Shelter In Place Warning" - )] - ShelterInPlace, - - /// Snow Squall - #[strum( - message = "Snow Squall", - detailed_message = "Snow Squall %", - props(weather = "") - )] - SnowSquall, - - /// Special Marine - #[strum( - message = "Special Marine", - detailed_message = "Special Marine %", - props(weather = "") - )] - SpecialMarine, - - /// Special Weather Statement - #[strum(message = "Special Weather Statement", props(weather = ""))] - SpecialWeatherStatement, - - /// Storm Surge - #[strum( - message = "Storm Surge", - detailed_message = "Storm Surge %", - props(weather = "") - )] - StormSurge, - - /// Tornado Warning - #[strum( - message = "Tornado", - detailed_message = "Tornado %", - props(weather = "") - )] - Tornado, - - /// Tropical Storm - #[strum( - message = "Tropical Storm", - detailed_message = "Tropical Storm %", - props(weather = "") - )] - TropicalStorm, - - /// Tsunami - #[strum( - message = "Tsunami", - detailed_message = "Tsunami %", - props(weather = "") - )] - Tsunami, - - /// Volcano - #[strum(message = "Volcano", detailed_message = "Volcano Warning")] - Volcano, - - /// Winter Storm - #[strum( - message = "Winter Storm", - detailed_message = "Winter Storm %", - props(weather = "") - )] - WinterStorm, - - /// Unrecognized phenomenon - /// - /// A catch-all for unrecognized event codes which either did not - /// decode properly or are not known to sameold. If you encounter - /// an Unrecognized event code in a production message, please - /// [report it as a bug](https://github.com/cbs228/sameold/issues) - /// right away. - #[strum(message = "Unrecognized", detailed_message = "Unrecognized %")] - Unrecognized, -} - -impl Phenomenon { - /// Describes the event without its accompanying severity information. - /// For example, - /// - /// ``` - /// # use sameold::Phenomenon; - /// assert_eq!(Phenomenon::RadiologicalHazard.as_brief_str(), "Radiological Hazard"); - /// ``` - /// - /// as opposed to the full human-readable description of the event code, - /// "Radiological Hazard *Warning*." If you want the full description, - /// use [`EventCode`](crate::EventCode) instead. - pub fn as_brief_str(&self) -> &'static str { - self.get_message().expect("missing phenomenon message") - } - - /// True if the phenomenon is associated with a national activation - /// - /// Returns true if the underlying event code is *typically* used - /// for national activations. This includes both live - /// National Emergency Messages and the National Periodic Test. - /// - /// Clients should consult the message's location codes to - /// determine if the message actually has national scope. - pub fn is_national(&self) -> bool { - self.get_str("national").is_some() - } - - /// True if the phenomenon is associated with tests - /// - /// Returns true if the underlying event code is used only for - /// tests. Test messages do not represent actual, real-world conditions. - /// Test messages should also have a - /// [`SignificanceLevel::Test`](crate::SignificanceLevel::Test). - pub fn is_test(&self) -> bool { - self.get_str("test").is_some() - } - - /// True if the represented phenomenon is weather - /// - /// In the United States, weather phenomenon codes like - /// "Severe Thunderstorm Warning" (`SVR`) are typically - /// only issued by the National Weather Service. The list of - /// weather event codes is taken from: - /// - /// * "National Weather Service Instruction 10-1708," 11 Dec 2017, - /// - /// - /// Not all **natural phenomenon** are considered **weather.** - /// Volcanoes, avalanches, and wildfires are examples of non-weather - /// phenomenon that are naturally occurring. The National Weather - /// Service does not itself issue these types of alerts; they are - /// generally left to state and local authorities. - pub fn is_weather(&self) -> bool { - self.get_str("weather").is_some() - } - - /// True if the represented phenomenon is not weather - /// - /// The opposite of [`Phenomenon::is_weather()`]. The list of - /// non-weather event codes available for national, state, and/or - /// local use is taken from: - /// - /// * "National Weather Service Instruction 10-1708," 11 Dec 2017, - /// - pub fn is_non_weather(&self) -> bool { - !self.is_weather() - } - - /// True if the phenomenon is not recognized - /// - /// ``` - /// # use sameold::Phenomenon; - /// assert!(Phenomenon::Unrecognized.is_unrecognized()); - /// ``` - pub fn is_unrecognized(&self) -> bool { - self == &Self::Unrecognized - } - - /// True if the phenomenon is recognized - /// - /// ``` - /// # use sameold::Phenomenon; - /// assert!(Phenomenon::TropicalStorm.is_recognized()); - /// ``` - /// - /// The opposite of [`is_unrecognized()`](Phenomenon::is_unrecognized). - pub fn is_recognized(&self) -> bool { - !self.is_unrecognized() - } - - /// Pattern string for full representation - /// - /// Returns a string like "`Tornado %`" that is the full string - /// representation of a SAME event code, with significance information. - /// `%` signs should be replaced with a textual representation of - /// the event code's significance level. - pub(crate) fn as_full_pattern_str(&self) -> &'static str { - self.get_detailed_message() - .unwrap_or_else(|| self.get_message().expect("missing phenomenon message")) - } -} - -impl Default for Phenomenon { - fn default() -> Self { - Self::Unrecognized - } -} - -impl std::fmt::Display for Phenomenon { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.as_brief_str().fmt(f) - } -} - -impl AsRef for Phenomenon { - fn as_ref(&self) -> &str { - self.as_brief_str() - } -} - -#[cfg(test)] -mod tests { - use strum::IntoEnumIterator; - - use super::*; - - #[test] - fn test_national() { - assert!(Phenomenon::NationalEmergency.is_national()); - assert!(Phenomenon::NationalEmergency.is_non_weather()); - assert!(Phenomenon::NationalPeriodicTest.is_national()); - assert!(Phenomenon::NationalPeriodicTest.is_non_weather()); - assert!(!Phenomenon::Hurricane.is_national()); - assert!(Phenomenon::Hurricane.is_weather()); - } - - // all phenomenon have messages and are either tests, - // weather, or non-weather - #[test] - fn test_property_completeness() { - for phenom in Phenomenon::iter() { - // these must not panic - phenom.as_brief_str(); - phenom.as_full_pattern_str(); - - if phenom.is_test() || phenom.is_national() { - assert!(phenom.is_non_weather()); - } - if phenom.is_weather() { - assert!(!phenom.is_test()); - } - } - } -} diff --git a/crates/sameold/src/message/significance.rs b/crates/sameold/src/message/significance.rs deleted file mode 100644 index 0c45cee..0000000 --- a/crates/sameold/src/message/significance.rs +++ /dev/null @@ -1,219 +0,0 @@ -//! Significance level - -use std::fmt; - -use strum::EnumMessage; - -/// SAME message significance level -/// -/// Usually constructed as part of an [`EventCode`](crate::EventCode). -/// See also [`MessageHeader::event()`](crate::MessageHeader::event) -/// -/// Three-letter SAME codes sometimes use the last letter to -/// indicate *significance* or severity. -/// -/// | Code | Significance | -/// |---------|---------------------------------------------------| -/// | `xxT` | [test](crate::SignificanceLevel::Test) | -/// | `xxS` | [statement](crate::SignificanceLevel::Statement) | -/// | `xxE` | [emergency](crate::SignificanceLevel::Emergency) | -/// | `xxA` | [watch](crate::SignificanceLevel::Watch) | -/// | `xxW` | [warning](crate::SignificanceLevel::Warning) | -/// -/// There are many message codes which do not follow this standard—and -/// some even contradict it. sameold knows the correct significance -/// code for these special cases, and the -/// [event](crate::MessageHeader::event) API will return it. -/// -/// Significance codes can be converted directly from or to string. -/// -/// ``` -/// use sameold::SignificanceLevel; -/// -/// assert_eq!(SignificanceLevel::Watch, SignificanceLevel::from("A")); -/// assert_eq!(SignificanceLevel::Test, SignificanceLevel::from("T")); -/// -/// assert_eq!("Test", SignificanceLevel::Test.as_display_str()); -/// assert_eq!("Test", format!("{}", SignificanceLevel::Test)); -/// assert_eq!("T", SignificanceLevel::Test.as_code_str()); -/// assert_eq!("T", format!("{:#}", SignificanceLevel::Test)); -/// ``` -/// -/// Significance levels are `Ord`. Lower significance levels -/// represent less urgent messages, such as tests and statements. -/// Higher significance levels represent more important or urgent -/// messages which may merit a "noisy" notification. -/// -/// ``` -/// # use sameold::SignificanceLevel; -/// assert!(SignificanceLevel::Test < SignificanceLevel::Warning); -/// assert!(SignificanceLevel::Watch < SignificanceLevel::Warning); -/// ``` -/// -/// Unrecognized significance levels are quietly represented as -/// [`SignificanceLevel::Unknown`]. Clients are encouraged to treat -/// messages with this significance level as a Warning. -/// -/// ``` -/// # use sameold::SignificanceLevel; -/// assert_eq!(SignificanceLevel::Unknown, SignificanceLevel::from("")); -/// assert!(SignificanceLevel::Unknown >= SignificanceLevel::Warning); -/// ``` -#[derive( - Clone, - Copy, - Debug, - PartialEq, - Eq, - PartialOrd, - Ord, - Hash, - strum_macros::EnumMessage, - strum_macros::EnumIter, -)] -#[repr(u8)] -pub enum SignificanceLevel { - /// Test - /// - /// A message intended only for testing purposes. "This is only a test." - #[strum(serialize = "T", detailed_message = "Test")] - Test, - - /// Statement - /// - /// > A message containing follow up information to a warning, watch, - /// > or emergency (NWSI 10-1712). - #[strum(serialize = "S", detailed_message = "Statement")] - Statement, - - /// Emergency - /// - /// > An event that by itself would not kill or injure or do property - /// > damage, but indirectly may cause other things to happen that - /// > result in a hazard. Example, a major power or telephone loss in - /// > a large city alone is not a direct hazard but disruption to - /// > other critical services could create a variety of conditions - /// > that could directly threaten public safety (NWSI 10-1712). - #[strum(serialize = "E", detailed_message = "Emergency")] - Emergency, - - /// Watch - /// - /// > Meets the classification of a warning, but either the onset time, - /// > probability of occurrence, or location is uncertain (NWSI 10-1712). - #[strum(serialize = "A", detailed_message = "Watch")] - Watch, - - /// Warning (the most severe event) - /// - /// > Those events that alone pose a significant threat to public - /// > safety and/or property, probability of occurrence and location - /// > is high, and the onset time is relatively short (NWSI 10-1712). - #[strum(serialize = "W", detailed_message = "Warning")] - Warning, - - /// Unknown significance level - /// - /// No significance level could be determined, either by knowledge of - /// the complete event code or by examining the last character. - /// Clients are strongly advised to treat unknown-significance messages - /// as [`SignificanceLevel::Warning`]. - #[strum(serialize = "", detailed_message = "Warning")] - Unknown, -} - -impl SignificanceLevel { - /// Parse from string - /// - /// Parses a SAME significance level from a single-character - /// `code` like "`T`" for [`SignificanceLevel::Test`]. If the - /// input does not match a significance level, returns - /// [`SignificanceLevel::Unknown`]. - /// - /// The user is cautioned not to blindly convert the last - /// character of a SAME code to a `SignificanceLevel`. There - /// are many event codes like "`EVI`" which do not follow the - /// `SignificanceLevel` convention. - pub fn from(code: S) -> Self - where - S: AsRef, - { - match code.as_ref() { - "T" => Self::Test, - "S" => Self::Statement, - "E" => Self::Emergency, - "A" => Self::Watch, - "W" => Self::Warning, - _ => Self::Unknown, - } - } - - /// Human-readable string representation - /// - /// Converts to a human-readable string, like "`Warning`." - pub fn as_display_str(&self) -> &'static str { - self.get_detailed_message().expect("missing definition") - } - - /// SAME string representation - /// - /// Returns the one-character SAME code for this - /// `SignificanceLevel`. While this is *frequently* the last - /// character of the event code, there are almost as many - /// exceptions to this rule as there are codes which - /// follow it. - pub fn as_code_str(&self) -> &'static str { - self.get_serializations()[0] - } -} - -impl std::default::Default for SignificanceLevel { - fn default() -> Self { - SignificanceLevel::Unknown - } -} - -impl From<&str> for SignificanceLevel { - fn from(s: &str) -> SignificanceLevel { - SignificanceLevel::from(s) - } -} - -impl AsRef for SignificanceLevel { - fn as_ref(&self) -> &'static str { - self.as_code_str() - } -} - -impl fmt::Display for SignificanceLevel { - /// Printable string - /// - /// * The normal form is a human-readable string like "`Statement`" - /// * The alternate form is a one-character string like "`S`" - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if f.alternate() { - self.as_code_str().fmt(f) - } else { - self.as_display_str().fmt(f) - } - } -} - -#[cfg(test)] -mod tests { - use strum::IntoEnumIterator; - - use super::*; - - #[test] - fn test_str_conversion() { - for sig in SignificanceLevel::iter() { - if sig == SignificanceLevel::Unknown { - continue; - } - - let inp = SignificanceLevel::from(sig.as_code_str()); - assert_eq!(inp, sig); - } - } -} diff --git a/crates/sameold/src/receiver/assembler.rs b/crates/sameold/src/receiver/assembler.rs index 4b547bf..e4bd041 100644 --- a/crates/sameold/src/receiver/assembler.rs +++ b/crates/sameold/src/receiver/assembler.rs @@ -54,7 +54,7 @@ use log::debug; #[cfg(test)] use std::println as debug; -use crate::message::{Message, MessageResult}; +use crate::{Message, MessageResult}; use super::combiner; use super::output::TransportState; diff --git a/crates/sameold/src/receiver/combiner.rs b/crates/sameold/src/receiver/combiner.rs index 1a710a0..c861b91 100644 --- a/crates/sameold/src/receiver/combiner.rs +++ b/crates/sameold/src/receiver/combiner.rs @@ -13,7 +13,7 @@ use std::println as debug; use arrayvec::ArrayVec; use super::assembler::Burst; -use crate::message::{Message, MessageResult}; +use crate::{Message, MessageResult}; /// Convert bursts into a fully-parsed SAME message /// diff --git a/crates/sameold/src/receiver/output.rs b/crates/sameold/src/receiver/output.rs index 0bdb028..b9c9dbf 100644 --- a/crates/sameold/src/receiver/output.rs +++ b/crates/sameold/src/receiver/output.rs @@ -1,4 +1,4 @@ -use crate::message::{Message, MessageResult}; +use crate::{Message, MessageResult}; /// Full SAME receiver status ///