diff --git a/Cargo.lock b/Cargo.lock index 2292766..0b4650e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -446,6 +446,48 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", +] + [[package]] name = "pretty_env_logger" version = "0.5.0" @@ -474,6 +516,21 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rawpointer" version = "0.2.1" @@ -558,6 +615,7 @@ dependencies = [ "nalgebra", "num-complex 0.3.1", "num-traits", + "phf", "regex", "slice-ring-buffer", "strum", @@ -578,6 +636,12 @@ dependencies = [ "wide", ] +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "slice-ring-buffer" version = "0.3.3" diff --git a/crates/samedec/README.md b/crates/samedec/README.md index a63b532..7d2a79f 100644 --- a/crates/samedec/README.md +++ b/crates/samedec/README.md @@ -273,13 +273,15 @@ The child process receives the following additional environment variables: * `SAMEDEC_EVT`: the three-character SAME event code, like "`RWT`" -* `SAMEDEC_EVENT`: human-readable event name: "`Required Weekly Test`." If the - event code is not known, and it its significance level is also unknown, then - this string will be "`Unrecognized`." +* `SAMEDEC_EVENT`: human-readable event description, including its significance + level: "`Required Weekly Test`." If the event code is not known, and it its + significance level is also unknown, then this string will be + "`Unrecognized Warning`." * `SAMEDEC_SIGNIFICANCE`: one-character significance level. This variable will be empty if the significance level could not be determined (i.e., because the event code is unknown). + * `T`: Test * `M`: Message * `S`: Statement diff --git a/crates/samedec/src/app.rs b/crates/samedec/src/app.rs index 93bcbdc..6ec0ef3 100644 --- a/crates/samedec/src/app.rs +++ b/crates/samedec/src/app.rs @@ -260,7 +260,7 @@ mod tests { use super::*; use chrono::{Duration, TimeZone, Utc}; - use sameold::EventCode; + use sameold::Phenomenon; #[test] fn test_make_demo_message() { @@ -270,7 +270,7 @@ mod tests { Message::StartOfMessage(hdr) => hdr, _ => unreachable!(), }; - assert_eq!(msg.event().unwrap(), EventCode::PracticeDemoWarning); + assert_eq!(msg.event().phenomenon(), Phenomenon::PracticeDemoWarning); assert_eq!(msg.issue_datetime(&tm).unwrap(), tm); assert_eq!(msg.valid_duration(), Duration::minutes(15)); } diff --git a/crates/samedec/src/spawner.rs b/crates/samedec/src/spawner.rs index 7dec365..8ac1b6c 100644 --- a/crates/samedec/src/spawner.rs +++ b/crates/samedec/src/spawner.rs @@ -7,7 +7,7 @@ use std::io; use std::process::{Child, Command, Stdio}; use chrono::{DateTime, Utc}; -use sameold::{MessageHeader, UnrecognizedEventCode}; +use sameold::MessageHeader; /// Spawn a child process to handle the given message /// @@ -54,8 +54,11 @@ where header.originator().as_display_str(), ) .env(childenv::SAMEDEC_EVT, header.event_str()) - .env(childenv::SAMEDEC_EVENT, msg_to_event(&header)) - .env(childenv::SAMEDEC_SIGNIFICANCE, msg_to_significance(header)) + .env(childenv::SAMEDEC_EVENT, header.event().to_string()) + .env( + childenv::SAMEDEC_SIGNIFICANCE, + header.event().significance().as_code_str(), + ) .env(childenv::SAMEDEC_LOCATIONS, locations.join(" ")) .env(childenv::SAMEDEC_ISSUETIME, issue_ts) .env(childenv::SAMEDEC_PURGETIME, purge_ts) @@ -143,23 +146,6 @@ mod childenv { pub const SAMEDEC_PURGETIME: &str = "SAMEDEC_PURGETIME"; } -// convert message event code to string -fn msg_to_event(msg: &MessageHeader) -> String { - match msg.event() { - Ok(evt) => evt.as_display_str().to_owned(), - Err(err) => format!("{}", err), - } -} - -// convert message event code to significance -fn msg_to_significance(msg: &MessageHeader) -> &'static str { - match msg.event() { - Ok(evt) => evt.to_significance_level().as_str(), - Err(UnrecognizedEventCode::WithSignificance(sl)) => sl.as_str(), - Err(UnrecognizedEventCode::Unrecognized) => "", - } -} - // convert DateTime to UTC unix timestamp in seconds, as string fn time_to_unix_str(tm: DateTime) -> String { format!("{}", tm.format("%s")) @@ -169,15 +155,6 @@ fn time_to_unix_str(tm: DateTime) -> String { mod tests { use super::*; - use sameold::MessageHeader; - - #[test] - fn test_msg_to_significance() { - const MSG: &str = "ZCZC-WXR-RWT-012345-567890-888990+0351-3662322-NOCALL-"; - let msg = MessageHeader::new(MSG).unwrap(); - assert_eq!("T", msg_to_significance(&msg)); - } - #[test] fn test_time_to_unix_str() { let dt: DateTime = DateTime::parse_from_rfc2822("Wed, 18 Feb 2015 23:16:09 GMT") diff --git a/crates/sameold/Cargo.toml b/crates/sameold/Cargo.toml index 32eeed4..46ac801 100644 --- a/crates/sameold/Cargo.toml +++ b/crates/sameold/Cargo.toml @@ -19,6 +19,7 @@ log = "0.4" nalgebra = "^0.32.2" num-complex = "^0.3.1" num-traits = "^0.2" +phf = {version = "^0.11", features = ["macros"]} regex = "^1.5.5" slice-ring-buffer = "^0.3" strum = "^0.21" diff --git a/crates/sameold/README.md b/crates/sameold/README.md index 1a11bbf..e5750ae 100644 --- a/crates/sameold/README.md +++ b/crates/sameold/README.md @@ -129,9 +129,9 @@ carrier signals before and during message decoding. ### Interpreting Messages -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 +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 * describes the event; and * provides instructions to the listener. @@ -148,21 +148,25 @@ If this was the header string received, then you could decode `hdr` from the previous example as follows: ```rust -use sameold::{EventCode, Originator, SignificanceLevel}; +use sameold::{Phenomenon, Originator, SignificanceLevel}; // what organization originated the message? assert_eq!(Originator::NationalWeatherService, hdr.originator()); -// event code -// in actual implementations, handle this error gracefully! -let evt = hdr.event().expect("unknown event code"); -assert_eq!(EventCode::RequiredWeeklyTest, evt); +// parse SAME event code `RWT` +let evt = hdr.event(); -// events have a "significance level" which describes how -// urgent or actual they are -assert_eq!(SignificanceLevel::Test, evt.to_significance_level()); +// the Phenomenon describes what is occurring +assert_eq!(Phenomenon::RequiredWeeklyTest, evt.phenomenon()); + +// the SignificanceLevel indicates the overall severity and/or +// how intrusive or noisy the alert should be +assert_eq!(SignificanceLevel::Test, evt.significance()); assert!(SignificanceLevel::Test < SignificanceLevel::Warning); +// Display to the user +assert_eq!("Required Weekly Test", &format!("{}", evt)); + // location codes are accessed by iterator let first_location = hdr.location_str_iter().next(); assert_eq!(Some("012345"), first_location); @@ -171,10 +175,12 @@ assert_eq!(Some("012345"), first_location); SAME messages are always transmitted three times for redundancy. When decoding the message header, `sameold` will use all three transmissions together to improve decoding. Only one -[`Message::StartOfMessage`] is output for all three header transmissions. +[`Message::StartOfMessage`](https://docs.rs/sameold/latest/sameold/enum.Message.html#variant.StartOfMessage) +is output for all three header transmissions. The trailers which denote the end of the message are **not** subject to -this error-correction process. One [`Message::EndOfMessage`] is -output for every trailer received. There may be up to three +this error-correction process. One +[`Message::EndOfMessage`](https://docs.rs/sameold/latest/sameold/enum.Message.html#variant.EndOfMessage) +is output for every trailer received. There may be up to three `EndOfMessage` output for every complete SAME message. ## Background diff --git a/crates/sameold/src/eventcodes.rs b/crates/sameold/src/eventcodes.rs new file mode 100644 index 0000000..d831ee4 --- /dev/null +++ b/crates/sameold/src/eventcodes.rs @@ -0,0 +1,248 @@ +//! # 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 26d0c1b..3e5e0f5 100644 --- a/crates/sameold/src/lib.rs +++ b/crates/sameold/src/lib.rs @@ -144,8 +144,9 @@ //! `hdr` from the previous example as follows: //! //! ``` +//! # use std::fmt; //! # use sameold::{MessageHeader}; -//! use sameold::{EventCode, Originator, SignificanceLevel}; +//! use sameold::{Phenomenon, Originator, SignificanceLevel}; //! # let hdr = MessageHeader::new( //! # "ZCZC-WXR-RWT-012345-567890-888990+0015-0321115-KLOX/NWS-" //! # ).expect("fail to parse"); @@ -153,16 +154,20 @@ //! // what organization originated the message? //! assert_eq!(Originator::NationalWeatherService, hdr.originator()); //! -//! // event code -//! // in actual implementations, handle this error gracefully! -//! let evt = hdr.event().expect("unknown event code"); -//! assert_eq!(EventCode::RequiredWeeklyTest, evt); +//! // parse SAME event code `RWT` +//! let evt = hdr.event(); //! -//! // events have a "significance level" which describes how -//! // urgent or actual they are -//! assert_eq!(SignificanceLevel::Test, evt.to_significance_level()); +//! // the Phenomenon describes what is occurring +//! assert_eq!(Phenomenon::RequiredWeeklyTest, evt.phenomenon()); +//! +//! // the SignificanceLevel indicates the overall severity and/or +//! // how intrusive or noisy the alert should be +//! assert_eq!(SignificanceLevel::Test, evt.significance()); //! assert!(SignificanceLevel::Test < SignificanceLevel::Warning); //! +//! // Display to the user +//! assert_eq!("Required Weekly Test", &format!("{}", evt)); +//! //! // location codes are accessed by iterator //! let first_location = hdr.location_str_iter().next(); //! assert_eq!(Some("012345"), first_location); @@ -224,12 +229,13 @@ #![deny(unsafe_code)] #![warn(missing_docs)] +pub mod eventcodes; mod message; mod receiver; pub use message::{ - EventCode, EventCodeIter, InvalidDateErr, Message, MessageDecodeErr, MessageHeader, - MessageResult, Originator, SignificanceLevel, UnknownSignificanceLevel, UnrecognizedEventCode, + EventCode, InvalidDateErr, Message, MessageDecodeErr, MessageHeader, MessageResult, Originator, + Phenomenon, SignificanceLevel, }; pub use receiver::{ EqualizerBuilder, LinkState, SameEvent, SameEventType, SameReceiver, SameReceiverBuilder, diff --git a/crates/sameold/src/message.rs b/crates/sameold/src/message.rs index af7f3e1..6f2c37c 100644 --- a/crates/sameold/src/message.rs +++ b/crates/sameold/src/message.rs @@ -1,7 +1,9 @@ //! SAME message ASCII encoding and decoding -mod event; +mod eventcode; mod originator; +mod phenomenon; +mod significance; use std::convert::TryFrom; use std::fmt; @@ -12,10 +14,10 @@ use lazy_static::lazy_static; use regex::Regex; use thiserror::Error; -pub use event::{ - EventCode, EventCodeIter, SignificanceLevel, UnknownSignificanceLevel, UnrecognizedEventCode, -}; +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; @@ -134,6 +136,11 @@ impl Message { 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-` @@ -250,9 +257,10 @@ impl MessageHeader { /// Originator code /// /// The ultimate source of the message, such as - /// `Originator::WeatherService` for the National Weather Service + /// [`Originator::NationalWeatherService`] for the + /// National Weather Service pub fn originator(&self) -> Originator { - Originator::from((self.originator_str(), self.callsign())) + Originator::from_org_and_call(self.originator_str(), self.callsign()) } /// Originator code (as string) @@ -277,64 +285,67 @@ impl MessageHeader { /// Event code /// - /// Decodes the event code into an enumerated type. - /// For example, messages which contain an - /// [`event_str()`](#method.event_str) of "`RWT`" will decode - /// as [`EventCode::RequiredWeeklyTest`](EventCode#variant.RequiredWeeklyTest). + /// Decodes the SAME event code (like `RWT`) into an + /// [`EventCode`], which is a combination of: /// - /// If the event code is unrecognized, an error is returned. - /// An error here does **NOT** mean that the message is - /// invalid or should be discarded. Instead, if the - /// error is - /// [`WithSignificance`](UnrecognizedEventCode#variant.WithSignificance), - /// then you should treat it as a valid (but unknown) - /// message at the given significance level. This will help - /// your application react correctly if new codes are - /// added in the future. + /// * [`phenomenon()`](Phenomenon), which describes what + /// is occurring; and /// - /// Event codes can be converted to human-readable strings. + /// * [`significance()`](SignificanceLevel), which indicates the + /// overall severity and/or how "noisy" or intrusive the alert + /// should be. /// - /// ``` - /// use sameold::EventCode; + /// `EventCode` Display as a human-readable string which describes + /// the SAME code. For example, "`TOR`" displays as "Tornado Warning." /// - /// assert_eq!("Required Weekly Test", (EventCode::RequiredWeeklyTest).as_display_str()); - /// assert_eq!( - /// "Required Weekly Test", - /// format!("{}", EventCode::RequiredWeeklyTest) - /// ); /// ``` + /// # use std::fmt; + /// use sameold::{MessageHeader, Phenomenon, SignificanceLevel}; /// - /// All `EventCode` are mapped to a [significance level](crate::SignificanceLevel). - /// This may be useful when deciding how to handle the event. + /// 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"); /// ``` - /// # use sameold::{EventCode, SignificanceLevel}; /// - /// let lvl = (EventCode::RequiredWeeklyTest).to_significance_level(); - /// assert_eq!(lvl, SignificanceLevel::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. + /// /// ``` - pub fn event(&self) -> Result { - EventCode::try_from(self.event_str()) + /// # 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 which is *generally* formatted - /// according to severity level. - /// - /// - `xxT`: Test - /// - `xxS`: Statement / Advisory - /// - `xxA`: Watch - /// - `xxW`: Warning (generally most severe events) + /// 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. /// - /// Major exceptions to this are the codes `SVR` - /// ("Severe Thunderstorm Warning") and `TOR` - /// ("Tornado Warning"), which are among the most common - /// messages in the United States. Plenty of other codes - /// also do not adhere to this standard. - /// - /// The event code returned is three characters but is - /// not guaranteed to be one of the above. + /// 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] } @@ -811,9 +822,9 @@ mod tests { .expect("bad msg"); assert_eq!(msg.originator_str(), "WXR"); - assert_eq!(Originator::WeatherService, msg.originator()); + assert_eq!(Originator::NationalWeatherService, msg.originator()); assert_eq!(msg.event_str(), "RWT"); - assert_eq!(msg.event().unwrap(), EventCode::RequiredWeeklyTest); + 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"); diff --git a/crates/sameold/src/message/event.rs b/crates/sameold/src/message/event.rs deleted file mode 100644 index d4d8d23..0000000 --- a/crates/sameold/src/message/event.rs +++ /dev/null @@ -1,662 +0,0 @@ -//! SAME/EAS Event Codes - -use std::convert::TryFrom; -use std::fmt; -use std::str::FromStr; - -use strum::{EnumMessage, EnumProperty}; -use thiserror::Error; - -/// SAME message significance level -/// -/// Usually constructed as part of an [`EventCode`]. -/// See also [MessageHeader::event()](crate::MessageHeader#method.event) -/// -/// Significance levels have a single-character text -/// representation, like "`T`" for Test. You can attempt to -/// convert from string: -/// -/// ``` -/// # use std::convert::TryFrom; -/// use sameold::{SignificanceLevel, UnknownSignificanceLevel}; -/// -/// assert_eq!(SignificanceLevel::Watch, SignificanceLevel::try_from("zzA").unwrap()); -/// assert_eq!(UnknownSignificanceLevel {}, SignificanceLevel::try_from("").unwrap_err()); -/// assert_eq!(SignificanceLevel::Test, SignificanceLevel::try_from("T").unwrap()); -/// ``` -/// -/// If a multi-character string is given as input, the last character -/// will be used. The last byte must be valid UTF-8. In all situations -/// where a valid `SignificanceLevel` can't be constructed, an -/// error is returned. -/// -/// 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); -/// ``` -#[derive( - Clone, - Copy, - Debug, - PartialEq, - Eq, - PartialOrd, - Ord, - Hash, - strum_macros::EnumMessage, - strum_macros::EnumString, -)] -#[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, - - /// Message - /// - /// A non-emergency message - #[strum(serialize = "M", detailed_message = "Message")] - Message, - - /// 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, -} - -impl SignificanceLevel { - /// 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 *usually* the last - /// character of the `EventCode`, there are many exceptions - /// to this rule. - pub fn as_str(&self) -> &'static str { - self.get_serializations()[0] - } -} - -impl AsRef for SignificanceLevel { - fn as_ref(&self) -> &'static str { - self.as_str() - } -} - -impl fmt::Display for SignificanceLevel { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.as_display_str().fmt(f) - } -} - -impl TryFrom<&str> for SignificanceLevel { - type Error = UnknownSignificanceLevel; - - /// Convert from string representation - /// - /// Matches a standard-form EAS event code, such as `xxT` for - /// Test, and converts it to its enumerated type. If the given - /// string does not end in a significance level, - /// `UnknownSignificanceLevel` is returned. - fn try_from(inp: &str) -> Result { - let s = last_ascii_character(inp).ok_or(UnknownSignificanceLevel {})?; - SignificanceLevel::from_str(s).map_err(|_| UnknownSignificanceLevel {}) - } -} - -/// Unknown significance level -/// -/// The event code is not known, and we were unable to determine -/// a significance level from it. -#[derive(Error, Clone, Debug, PartialEq, Eq)] -#[error("The event significance level could not be determined")] -pub struct UnknownSignificanceLevel {} - -/// SAME message event code -/// -/// Usually constructed via -/// [MessageHeader::event()](crate::MessageHeader#method.event). -/// Event codes were obtained from -/// . -/// -/// Converting to string via `.as_ref()` will yield the SAME -/// event code string. You can also obtain a human-readable message -/// -/// ``` -/// use sameold::EventCode; -/// -/// assert_eq!("RWT", (EventCode::RequiredWeeklyTest).as_ref()); -/// assert_eq!("Required Weekly Test", (EventCode::RequiredWeeklyTest).as_display_str()); -/// assert_eq!( -/// "Required Weekly Test", -/// format!("{}", EventCode::RequiredWeeklyTest) -/// ); -/// ``` -/// -/// All events are mapped to a [significance level](SignificanceLevel). -/// This may be useful when deciding how to handle the event. -/// -/// ``` -/// # use sameold::{EventCode, SignificanceLevel}; -/// -/// let lvl = (EventCode::RequiredWeeklyTest).to_significance_level(); -/// assert_eq!(lvl, SignificanceLevel::Test); -/// ``` -#[derive( - Clone, - Copy, - Debug, - PartialEq, - Eq, - Hash, - strum_macros::EnumMessage, - strum_macros::EnumString, - strum_macros::EnumProperty, - strum_macros::IntoStaticStr, - strum_macros::EnumIter, -)] -#[non_exhaustive] -#[repr(u8)] -pub enum EventCode { - /// Emergency Action Notification (begins national activation) - #[strum( - serialize = "EAN", - detailed_message = "Emergency Action Notification", - props(level = "W") - )] - EmergencyActionNotification, - - /// National Information Center (part of national activation) - #[strum( - serialize = "NIC", - detailed_message = "National Information Center", - props(level = "S") - )] - NationalInformationCenter, - - /// National Periodic Test - #[strum(serialize = "NPT", detailed_message = "National Periodic Test")] - NationalPeriodicTest, - - /// Required Monthly Test - #[strum(serialize = "RMT", detailed_message = "Required Monthly Test")] - RequiredMonthlyTest, - - /// Required Weekly Test - #[strum(serialize = "RWT", detailed_message = "Required Weekly Test")] - RequiredWeeklyTest, - - /// Administrative Message (state/local) - #[strum(serialize = "ADM", detailed_message = "Administrative Message")] - AdministrativeMessage, - - /// Avalanche Watch - #[strum(serialize = "AVA", detailed_message = "Avalanche Watch")] - AvalancheWatch, - - /// Avalanche Warning - #[strum(serialize = "AVW", detailed_message = "Avalanche Warning")] - AvalancheWarning, - - /// Blizzard Warning - #[strum(serialize = "BZW", detailed_message = "Blizzard Warning")] - BlizzardWarning, - - /// Blue Alert (state/local) - #[strum(serialize = "BLU", detailed_message = "Blue Alert", props(level = "W"))] - BlueAlert, - - /// Child Abduction Emergency (state/local) - #[strum(serialize = "CAE", detailed_message = "Child Abduction Emergency")] - ChildAbductionEmergency, - - /// Civil Danger Warning (state/local) - #[strum(serialize = "CDW", detailed_message = "Civil Danger Warning")] - CivilDangerWarning, - - /// Civil Emergency Message (state/local) - #[strum( - serialize = "CEM", - detailed_message = "Civil Emergency Message", - props(level = "W") - )] - CivilEmergencyMessage, - - /// Coastal Flood Warning - #[strum(serialize = "CFW", detailed_message = "Coastal Flood Warning")] - CoastalFloodWarning, - - /// Coastal Flood Warning - #[strum(serialize = "CFA", detailed_message = "Coastal Flood Watch")] - CoastalFloodWatch, - - /// Dust Storm Warning - #[strum(serialize = "DSW", detailed_message = "Dust Storm Warning")] - DustStormWarning, - - /// Earthquake Warning - #[strum(serialize = "EQW", detailed_message = "Earthquake Warning")] - EarthquakeWarning, - - /// Evacuation Immediate - #[strum( - serialize = "EVI", - detailed_message = "Evacuation Immediate", - props(level = "W") - )] - EvacuationImmediate, - - /// Extreme Wind Warning - #[strum(serialize = "EWW", detailed_message = "Extreme Wind Warning")] - ExtremeWindWarning, - - /// Fire Warning - #[strum(serialize = "FRW", detailed_message = "Fire Warning")] - FireWarning, - - /// Flash Flood Warning - #[strum(serialize = "FFW", detailed_message = "Flash Flood Warning")] - FlashFloodWarning, - - /// Flash Flood Watch - #[strum(serialize = "FFA", detailed_message = "Flash Flood Watch")] - FlashFloodWatch, - - /// Flash Flood Statement - #[strum(serialize = "FFS", detailed_message = "Flash Flood Statement")] - FlashFloodStatement, - - /// Flood Warning - #[strum(serialize = "FLW", detailed_message = "Flood Warning")] - FloodWarning, - - /// Flood Watch - #[strum(serialize = "FLA", detailed_message = "Flood Watch")] - FloodWatch, - - /// Flood Statement - #[strum(serialize = "FLS", detailed_message = "Flood Statement")] - FloodStatement, - - /// Hazardous Materials Warning - #[strum(serialize = "HMW", detailed_message = "Hazardous Materials Warning")] - HazardousMaterialsWarning, - - /// High Wind Warning - #[strum(serialize = "HWW", detailed_message = "High Wind Warning")] - HighWindWarning, - - /// High Wind Watch - #[strum(serialize = "HWA", detailed_message = "High Wind Watch")] - HighWindWatch, - - /// Hurricane Warning - #[strum(serialize = "HUW", detailed_message = "Hurricane Warning")] - HurricaneWarning, - - /// Hurricane Watch - #[strum(serialize = "HUA", detailed_message = "Hurricane Watch")] - HurricaneWatch, - - /// Hurricane Statement - #[strum(serialize = "HLS", detailed_message = "Hurricane Statement")] - HurricaneStatement, - - /// Law Enforcement Warning - #[strum(serialize = "LEW", detailed_message = "Law Enforcement Warning")] - LawEnforcementWarning, - - /// Local Area Emergency - #[strum(serialize = "LAE", detailed_message = "Local Area Emergency")] - LocalAreaEmergency, - - /// Network Message Notification - #[strum( - serialize = "NMN", - detailed_message = "Network Message Notification", - props(level = "M") - )] - NetworkMessageNotification, - - /// 911 Telephone Outage Emergency - #[strum(serialize = "TOE", detailed_message = "911 Telephone Outage Emergency")] - TelephoneOutageEmergency, - - /// Nuclear Power Plant Warning - #[strum(serialize = "NUW", detailed_message = "Nuclear Power Plant Warning")] - NuclearPowerPlantWarning, - - /// Practice/Demo Warning - #[strum( - serialize = "DMO", - detailed_message = "Practice/Demo Warning", - props(level = "W") - )] - PracticeDemoWarning, - - /// Radiological Hazard Warning - #[strum(serialize = "RHW", detailed_message = "Radiological Hazard Warning")] - RadiologicalHazardWarning, - - /// Severe Thunderstorm Warning - #[strum( - serialize = "SVR", - detailed_message = "Severe Thunderstorm Warning", - props(level = "W") - )] - SevereThunderstormWarning, - - /// Severe Thunderstorm Watch - #[strum(serialize = "SVA", detailed_message = "Severe Thunderstorm Watch")] - SevereThunderstormWatch, - - /// Severe Weather Statement - #[strum(serialize = "SVS", detailed_message = "Severe Weather Statement")] - SevereWeatherStatement, - - /// Shelter In Place Warning - #[strum(serialize = "SPW", detailed_message = "Shelter In Place Warning")] - ShelterInPlaceWarning, - - /// Special Marine Warning - #[strum(serialize = "SMW", detailed_message = "Special Marine Warning")] - SpecialMarineWarning, - - /// Special Weather Statement - #[strum(serialize = "SPS", detailed_message = "Special Weather Statement")] - SpecialWeatherStatement, - - /// Storm Surge Watch - #[strum(serialize = "SSA", detailed_message = "Storm Surge Watch")] - StormSurgeWatch, - - /// Storm Surge Warning - #[strum(serialize = "SSW", detailed_message = "Storm Surge Warning")] - StormSurgeWarning, - - /// Tornado Warning - #[strum( - serialize = "TOR", - detailed_message = "Tornado Warning", - props(level = "W") - )] - TornadoWarning, - - /// Tornado Watch - #[strum(serialize = "TOA", detailed_message = "Tornado Watch")] - TornadoWatch, - - /// Tropical Storm Warning - #[strum(serialize = "TRW", detailed_message = "Tropical Storm Warning")] - TropicalStormWarning, - - /// Tropical Storm Watch - #[strum(serialize = "TRA", detailed_message = "Tropical Storm Watch")] - TropicalStormWatch, - - /// Tsunami Warning - #[strum(serialize = "TSW", detailed_message = "Tsunami Warning")] - TsunamiWarning, - - /// Tsunami Watch - #[strum(serialize = "TSA", detailed_message = "Tsunami Watch")] - TsunamiWatch, - - /// Volcano Warning - #[strum(serialize = "VOW", detailed_message = "Volcano Warning")] - VolcanoWarning, - - /// Winter Storm Warning - #[strum(serialize = "WSW", detailed_message = "Winter Storm Warning")] - WinterStormWarning, - - /// Winter Storm Warning - #[strum(serialize = "WSA", detailed_message = "Winter Storm Watch")] - WinterStormWatch, -} - -impl EventCode { - /// Obtain event's significance level - /// - /// The significance level ranges from "`Test`" - /// (i.e., "this is only a test") to "`Warning`." Each - /// event code has a significance level associated with - /// it. The [`SignificanceLevel`](SignificanceLevel) - /// is useful for determining whether an event merits a - /// "noisy" and/or "immediate" alert for the message. - pub fn to_significance_level(&self) -> SignificanceLevel { - SignificanceLevel::try_from(self).expect("missing significance level definition") - } - - /// Human-readable string representation - /// - /// Converts to a human-readable string, like "`Required Monthly Test`." - pub fn as_display_str(&self) -> &'static str { - self.get_detailed_message() - .expect("missing human-readable definition") - } - - /// SAME string representation - /// - /// Returns the three-character SAME code for this - /// `EventCode`. - pub fn as_str(&self) -> &'static str { - self.get_serializations()[0] - } -} - -impl TryFrom<&str> for EventCode { - type Error = UnrecognizedEventCode; - - /// Convert from three-character SAME event code - /// - /// Converts an event code like "`SVR`" into its enumerated - /// type (`EventCode::SevereThunderstormWarning`). - /// - /// If the code is unrecognized, an error is returned. - /// An error here does **NOT** mean that the message is - /// invalid or should be discarded. Instead, if the - /// error is - /// [`WithSignificance`](UnrecognizedEventCode#variant.WithSignificance), - /// then you should treat it as a valid (but unknown) - /// message at the given significance level. This will help - /// your application react correctly if new codes are - /// added in the future. - fn try_from(inp: &str) -> Result { - Self::from_str(inp).map_err(|_| UnrecognizedEventCode::from(inp)) - } -} - -impl From<&EventCode> for SignificanceLevel { - /// Convert to significance level - fn from(evt: &EventCode) -> SignificanceLevel { - // if we define a level property, use that - // if the last character is valid, use that - // otherwise, it's a warning. - let lvl = evt - .get_str("level") - .unwrap_or_else(|| evt.get_serializations()[0]); - - SignificanceLevel::try_from(lvl).expect("missing significance level definition") - } -} - -impl AsRef for EventCode { - fn as_ref(&self) -> &'static str { - self.as_str() - } -} - -impl fmt::Display for EventCode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.as_display_str().fmt(f) - } -} - -/// An unrecognized SAME event code -/// -/// Even if the complete event code is unknown, the parser may -/// still be able to extract some meaning from it. Most new -/// messages end in the -/// [`SignificanceLevel`](SignificanceLevel). A new -/// "Derecho Warning" message, with fictitious code "`DEW`," -/// still ends in `W` for Warning. Your client application -/// should react to it accordingly as a life-threatening Warning, -/// even if your parser doesn't know what it is. -/// -/// If the code is unknown and can't be coerced to any of the -/// `SignificanceLevel`, then -/// `UnrecognizedEventCode::Unrecognized` is returned. -#[derive(Error, Debug, Clone, PartialEq, Eq)] - -pub enum UnrecognizedEventCode { - /// A completely unrecognizable event code - #[error("Unrecognized")] - Unrecognized, - - /// An unknown event code which *does* match a significance level - #[error("Unrecognized {0}")] - WithSignificance(SignificanceLevel), -} - -impl From<&str> for UnrecognizedEventCode { - /// Convert from a SAME event code - /// - /// Accepts either a three-character SAME event code or - /// a single-character significance level. Decodes it - /// as a `SignificanceLevel` if possible. - fn from(inp: &str) -> Self { - match SignificanceLevel::try_from(inp) { - Ok(sl) => Self::WithSignificance(sl), - Err(_) => Self::Unrecognized, - } - } -} - -// Get last character of the given ASCII string -// -// The last byte of `s` must be valid UTF-8. Returns reference -// to the last byte. -fn last_ascii_character<'a>(s: &'a str) -> Option<&'a str> { - if s.is_empty() { - None - } else { - s.get((s.len() - 1)..s.len()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use strum::IntoEnumIterator; - - #[test] - fn test_event_api() { - // Conversion to the Unrecognized… series - let evt = EventCode::try_from("!!!"); - assert_eq!(Err(UnrecognizedEventCode::Unrecognized), evt); - assert_eq!("Unrecognized", &format!("{}", evt.err().unwrap())); - - let evt2 = EventCode::try_from("??W").unwrap_err(); - assert_eq!( - UnrecognizedEventCode::WithSignificance(SignificanceLevel::Warning), - evt2 - ); - assert_eq!("Unrecognized Warning", &format!("{}", evt2)); - - assert_eq!( - UnrecognizedEventCode::WithSignificance(SignificanceLevel::Statement), - EventCode::try_from("SSS").unwrap_err() - ); - - // Conversion from string and significance level - let evt = EventCode::try_from("CEM").unwrap(); - assert_eq!(EventCode::CivilEmergencyMessage, evt); - assert_eq!(SignificanceLevel::Warning, evt.to_significance_level()); - assert_eq!("Civil Emergency Message", evt.as_display_str()); - - let evt = EventCode::try_from("NPT").unwrap(); - assert_eq!(EventCode::NationalPeriodicTest, evt); - assert_eq!(SignificanceLevel::Test, evt.to_significance_level()); - assert_eq!("National Periodic Test", evt.as_display_str()); - - let evt = EventCode::try_from("TOR").unwrap(); - assert_eq!(EventCode::TornadoWarning, evt); - assert_eq!(SignificanceLevel::Warning, evt.to_significance_level()); - assert_eq!("Tornado Warning", evt.as_display_str()); - } - - #[test] - fn test_event_completeness() { - // Did we define our list of Events correctly? This method helps us - // check them all - const REQUIRE_NUM_LIVE_CODES: u8 = 56; - assert_eq!( - REQUIRE_NUM_LIVE_CODES, - EventCode::WinterStormWatch as u8 + 1 - ); - - let mut code_set = - std::collections::HashSet::with_capacity(REQUIRE_NUM_LIVE_CODES as usize); - let mut name_set = - std::collections::HashSet::with_capacity(REQUIRE_NUM_LIVE_CODES as usize); - - for evt in EventCode::iter() { - // make sure code assignment and name assignments are unique - let eee: &str = evt.clone().into(); - assert!(code_set.insert(eee.to_owned())); - assert!(name_set.insert(evt.as_ref().to_owned())); - - // convert from code - let cmp = EventCode::from_str(eee).expect("can't back-convert event EEE code!"); - assert_eq!(cmp, evt); - - // convert to significance level does not panic - let _ = evt.to_significance_level(); - } - } -} diff --git a/crates/sameold/src/message/eventcode.rs b/crates/sameold/src/message/eventcode.rs new file mode 100644 index 0000000..e72357d --- /dev/null +++ b/crates/sameold/src/message/eventcode.rs @@ -0,0 +1,317 @@ +//! 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 index 98207f2..c9a0427 100644 --- a/crates/sameold/src/message/originator.rs +++ b/crates/sameold/src/message/originator.rs @@ -1,38 +1,45 @@ //! Originator code use std::fmt; -use std::str::FromStr; use strum::EnumMessage; /// SAME message originator code /// -/// See [Message::originator()](crate::Message#method.originator). -/// Originator codes may be converted `from()` their SAME string -/// representations. Using them `.as_ref()` or via `Display` will -/// show a human-readable string. -/// -/// The variants `NationalWeatherService` and `EnvironmentCanada` -/// must be constructed using both the originator string and the -/// station callsign. +/// 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("WXR"); -/// assert_eq!(Originator::WeatherService, orig); -/// assert_eq!("WXR", orig.as_ref()); -/// assert_eq!("Weather Service", orig.as_display_str()); -/// assert_eq!("Weather Service", &format!("{}", orig)); +/// 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()); +/// ``` /// -/// assert_eq!(Originator::Unknown, Originator::from("HUH")); +/// Originators Display a human-readable string: /// -/// let orig = Originator::from(("WXR", "KLOX/NWS")); +/// ``` +/// # use sameold::Originator; +/// # let orig = Originator::from_org_and_call("WXR", "KLOX/NWS"); /// assert_eq!("National Weather Service", orig.as_display_str()); -/// assert_eq!("WXR", orig.as_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(("WXR", "EC/GC/CA"))); +/// 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, @@ -41,7 +48,7 @@ pub enum Originator { /// An unknown (and probably invalid) Originator code /// /// Per NWSI 10-172, receivers should accept any originator code. - #[strum(serialize = "OOO", detailed_message = "Unknown Originator")] + #[strum(serialize = "", detailed_message = "Unknown Originator")] Unknown, /// Primary Entry Point station for national activations @@ -56,16 +63,19 @@ pub enum Originator { #[strum(serialize = "CIV", detailed_message = "Civil authorities")] CivilAuthority, - /// National Weather Service or Environment Canada - #[strum(serialize = "WXR", detailed_message = "Weather Service")] - WeatherService, - /// National Weather Service - #[strum(disabled, serialize = "WXR")] + #[strum(serialize = "WXR", detailed_message = "National Weather Service")] NationalWeatherService, /// Environment Canada - #[strum(disabled, serialize = "WXR")] + /// + /// 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) @@ -77,61 +87,55 @@ pub enum Originator { } 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 { - match self { - Originator::NationalWeatherService => "National Weather Service", - Originator::EnvironmentCanada => "Environment Canada", - _ => self.get_detailed_message().expect("missing definition"), - } + self.get_detailed_message().expect("missing definition") } /// SAME string representation /// - /// Returns the three-character SAME code for this - /// `Originator` - pub fn as_str(&self) -> &'static str { - self.get_serializations()[0] + /// 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 From<&str> for Originator { - fn from(s: &str) -> Originator { - match Originator::from_str(s) { - Ok(orig) => orig, - Err(_e) => Originator::Unknown, - } - } -} - -impl From<(&str, &str)> for Originator { - fn from(orig_and_call: (&str, &str)) -> Originator { - match Originator::from_str(orig_and_call.0) { - Ok(Originator::WeatherService) => { - if orig_and_call.1.ends_with("/NWS") { - Originator::NationalWeatherService - } else if orig_and_call.1.starts_with("EC/") { - Originator::EnvironmentCanada - } else { - Originator::WeatherService - } - } - Ok(orig) => orig, - Err(_e) => Originator::Unknown, - } +impl std::default::Default for Originator { + fn default() -> Self { + Self::Unknown } } impl AsRef for Originator { fn as_ref(&self) -> &'static str { - self.as_str() + self.as_code_str() } } impl fmt::Display for Originator { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.as_display_str().fmt(f) + 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 new file mode 100644 index 0000000..a078936 --- /dev/null +++ b/crates/sameold/src/message/phenomenon.rs @@ -0,0 +1,530 @@ +//! 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 new file mode 100644 index 0000000..62c2154 --- /dev/null +++ b/crates/sameold/src/message/significance.rs @@ -0,0 +1,200 @@ +//! 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) | +/// | `xxM` | [message](crate::SignificanceLevel::Message) | +/// | `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::EnumString, +)] +#[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, + + /// Message + /// + /// A non-emergency message + #[strum(serialize = "M", detailed_message = "Message")] + Message, + + /// 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, + { + str::parse(code.as_ref()).unwrap_or_default() + } + + /// 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) + } + } +} diff --git a/sample/two_and_two.22050.s16le.sh b/sample/two_and_two.22050.s16le.sh index fca62dc..a67636a 100644 --- a/sample/two_and_two.22050.s16le.sh +++ b/sample/two_and_two.22050.s16le.sh @@ -6,7 +6,7 @@ set -e exec 0>/dev/null [ "$SAMEDEC_EVENT" = "Severe Thunderstorm Warning" ] -[ "$SAMEDEC_ORIGINATOR" = "Weather Service" ] +[ "$SAMEDEC_ORIGINATOR" = "National Weather Service" ] [ "$SAMEDEC_SIGNIFICANCE" = "W" ] lifetime=$(( SAMEDEC_PURGETIME - SAMEDEC_ISSUETIME))