Skip to content

Commit af667d3

Browse files
committed
sameplace: split message parser into its own crate
The present `sameold` lib crate combines the digital modem with a message text parser. This is not necessary. * The interface from the modem to the text parser is just String. * The modem depends on Message for its API, but it doesn't care about the finer details of the contents—viz., event codes. To encourage re-use throughout the Rust ecosystem, we should create crates which are narrow in scope. Create a new crate for `Message` and friends. It includes the text-parser without any of the modem DSP. Documentation is given a touch-up.
1 parent 43392b1 commit af667d3

10 files changed

Lines changed: 2634 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/sameplace/Cargo.toml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
[package]
2+
name = "sameplace"
3+
rust-version = "1.70"
4+
description = "A SAME/EAS Message Parser"
5+
version = "0.1.0"
6+
authors = ["Colin S <3526918+cbs228@users.noreply.github.com>"]
7+
license = "MIT OR Apache-2.0"
8+
edition = "2021"
9+
homepage = "https://github.com/cbs228/sameold"
10+
repository = "https://github.com/cbs228/sameold.git"
11+
readme = "README.md"
12+
13+
[dependencies]
14+
lazy_static = "^1.4.0"
15+
phf = {version = "^0.11", features = ["macros"]}
16+
regex = "^1.5.5"
17+
strum = "^0.26"
18+
strum_macros = "^0.26"
19+
thiserror = "^2.0"
20+
21+
[dependencies.chrono]
22+
version = "^0.4"
23+
default-features = false
24+
features = ["clock", "std"]
25+
optional = true
26+
27+
[features]
28+
default = ["chrono"]

crates/sameplace/README.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# sameplace: A SAME/EAS Message Parser
2+
3+
This crate provides a text parser for
4+
[Specific Area Message Encoding](https://en.wikipedia.org/wiki/Specific_Area_Message_Encoding)
5+
(SAME). It provides machine- and human-friendly representations
6+
of these messages, with [event codes](https://docs.rs/sameplace/latest/sameplace/eventcodes/index.html) and
7+
[significance levels](https://docs.rs/sameplace/latest/sameplace/enum.SignificanceLevel.html).
8+
9+
For a complete CLI binary, see
10+
[`samedec`](https://crates.io/crates/samedec).
11+
12+
## Anatomy of a SAME message
13+
14+
SAME/EAS messages contain:
15+
16+
1. A digital header which provides machine-readable information
17+
2. An audio voice message, for human consumption
18+
3. A digital trailer which denotes the end of message.
19+
20+
The actual "message" part of a SAME message is the audio itself,
21+
which describes the event and provides instructions to the
22+
listener. The digital headers do **not** contain all the
23+
information as the voice message.
24+
25+
* For analog→digital decoding, see our companion library
26+
[`sameold`](https://docs.rs/sameold/latest/sameold/).
27+
28+
* For a complete program, which can also handle the voice
29+
message, see our companion binary crate
30+
[`samedec`](https://crates.io/crates/samedec).
31+
32+
## Interpreting Messages
33+
34+
The [`MessageHeader`](https://docs.rs/sameplace/latest/sameplace/struct.MessageHeader.html)
35+
type decodes a SAME header, like:
36+
37+
```txt
38+
ZCZC-WXR-RWT-012345-567890-888990+0015-0321115-KLOX/NWS-
39+
```
40+
41+
```rust
42+
use sameplace::{MessageHeader, Originator, Phenomenon, SignificanceLevel};
43+
44+
// decode the header string
45+
let hdr = MessageHeader::new(
46+
"ZCZC-WXR-RWT-012345-567890-888990+0015-0321115-KLOX/NWS-"
47+
).expect("fail to parse");
48+
49+
// what organization originated the message?
50+
assert_eq!(Originator::NationalWeatherService, hdr.originator());
51+
52+
// parse SAME event code `RWT`
53+
let evt = hdr.event();
54+
55+
// the Phenomenon describes what is occurring
56+
assert_eq!(Phenomenon::RequiredWeeklyTest, evt.phenomenon());
57+
58+
// the SignificanceLevel indicates the overall severity and/or
59+
// how intrusive or noisy the alert should be
60+
assert_eq!(SignificanceLevel::Test, evt.significance());
61+
assert!(SignificanceLevel::Test < SignificanceLevel::Warning);
62+
63+
// Display to the user
64+
assert_eq!("Required Weekly Test", &format!("{}", evt));
65+
66+
// location codes are accessed by iterator
67+
let first_location = hdr.location_str_iter().next();
68+
assert_eq!(Some("012345"), first_location);
69+
```
70+
71+
## Crate features
72+
73+
* `chrono`: Use chrono to calculate message
74+
[issuance times](chttps://docs.rs/sameplace/latest/sameplace/struct.MessageHeader.html#method.issue_datetime)
75+
and other fields as true UTC timestamps. If enabled, `chrono`
76+
becomes part of this crate's public API.
77+
78+
## MSRV Policy
79+
80+
A minimum supported rust version (MSRV) increase will be treated as a minor
81+
version bump.
82+
83+
## Contributing
84+
85+
Please read our
86+
[contributing guidelines](https://github.com/cbs228/sameold/blob/master/CONTRIBUTING.md)
87+
before opening any issues or PRs.
88+
89+
License: MIT OR Apache-2.0

crates/sameplace/src/eventcodes.rs

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
//! # List of SAME Events Codes Known to `sameplace`
2+
//!
3+
//! | `XYZ` | Description |
4+
//! |-------|----------------------------------------|
5+
//! | `ADR` | Administrative Message |
6+
//! | `AVA` | Avalanche Watch |
7+
//! | `AVW` | Avalanche Warning |
8+
//! | `BLU` | Blue Alert |
9+
//! | `BZW` | Blizzard Warning |
10+
//! | `CAE` | Child Abduction Emergency |
11+
//! | `CDW` | Civil Danger Warning |
12+
//! | `CEM` | Civil Emergency Message |
13+
//! | `CFA` | Coastal Flood Watch |
14+
//! | `CFW` | Coastal Flood Warning |
15+
//! | `DMO` | Practice/Demo Warning |
16+
//! | `DSW` | Dust Storm Warning |
17+
//! | `EAN` | National Emergency Message |
18+
//! | `EQW` | Earthquake Warning |
19+
//! | `EVI` | Evacuation Immediate |
20+
//! | `EWW` | Extreme Wind Warning |
21+
//! | `FFA` | Flash Flood Watch |
22+
//! | `FFS` | Flash Flood Statement |
23+
//! | `FFW` | Flash Flood Warning |
24+
//! | `FLA` | Flood Watch |
25+
//! | `FLS` | Flood Statement |
26+
//! | `FLW` | Flood Warning |
27+
//! | `FRW` | Fire Warning |
28+
//! | `FSW` | Flash Freeze Warning |
29+
//! | `FZW` | Freeze Warning |
30+
//! | `HLS` | Hurricane Local Statement |
31+
//! | `HMW` | Hazardous Materials Warning |
32+
//! | `HUA` | Hurricane Watch |
33+
//! | `HUW` | Hurricane Warning |
34+
//! | `HWA` | High Wind Watch |
35+
//! | `HWW` | High Wind Warning |
36+
//! | `LAE` | Local Area Emergency |
37+
//! | `LEW` | Law Enforcement Warning |
38+
//! | `NAT` | National Audible Test |
39+
//! | `NIC` | National Information Center |
40+
//! | `NMN` | Network Notification Message |
41+
//! | `NPT` | National Periodic Test |
42+
//! | `NST` | National Silent Test |
43+
//! | `NUW` | Nuclear Power Plant Warning |
44+
//! | `RHW` | Radiological Hazard Warning |
45+
//! | `RMT` | Required Monthly Test |
46+
//! | `RWT` | Required Weekly Test |
47+
//! | `SMW` | Special Marine Warning |
48+
//! | `SPS` | Special Weather Statement |
49+
//! | `SPW` | Shelter In-Place warning |
50+
//! | `SQW` | Snow Squall Warning |
51+
//! | `SSA` | Storm Surge Watch |
52+
//! | `SSW` | Storm Surge Warning |
53+
//! | `SVA` | Severe Thunderstorm Watch |
54+
//! | `SVR` | Severe Thunderstorm Warning |
55+
//! | `SVS` | Severe Weather Statement |
56+
//! | `TOA` | Tornado Watch |
57+
//! | `TOE` | 911 Telephone Outage Emergency |
58+
//! | `TOR` | Tornado Warning |
59+
//! | `TRA` | Tropical Storm Watch |
60+
//! | `TRW` | Tropical Storm Warning |
61+
//! | `TSA` | Tsunami Watch |
62+
//! | `TSW` | Tsunami Warning |
63+
//! | `VOW` | Volcano Warning |
64+
//! | `WSA` | Winter Storm Watch |
65+
//! | `WSW` | Winter Storm Warning |
66+
//!
67+
//! SAME event codes for the United States are given in
68+
//! [NWSI 10-1712](https://www.nws.noaa.gov/directives/sym/pd01017012curr.pdf).
69+
//!
70+
//! ## See Also
71+
//!
72+
//! * [`EventCode`](crate::EventCode)
73+
//! * [`MessageHeader::event()`](crate::MessageHeader::event)
74+
75+
use phf::phf_map;
76+
77+
use crate::{Phenomenon, SignificanceLevel};
78+
79+
/// An entry in [`CODEBOOK`].
80+
pub(crate) type CodeEntry = (Phenomenon, SignificanceLevel);
81+
82+
/// Lookup a three-character SAME event code in the database
83+
///
84+
/// If the input `code` matches a `CodeEntry` that is known to
85+
/// sameplace, returns it. If no exact match could be found, the
86+
/// third character is matched as a significance level only. If
87+
/// even that does not match, returns `None`.
88+
pub(crate) fn parse_event<S>(code: S) -> Option<CodeEntry>
89+
where
90+
S: AsRef<str>,
91+
{
92+
let code = code.as_ref();
93+
if code.len() != 3 {
94+
// invalid
95+
return None;
96+
}
97+
98+
// try the full three-character code first
99+
lookup_threecharacter(code)
100+
// if not, lookup the two-character code + significance
101+
.or_else(|| lookup_twocharacter(code))
102+
// if not, is the last character a known SignificanceLevel?
103+
.or_else(|| lookup_onecharacter(code))
104+
// otherwise → None
105+
}
106+
107+
/// Database of three-character SAME event codes.
108+
///
109+
/// All three-character codes imply a significance level:
110+
/// the `RWT` will always have a significance of `Test`.
111+
static CODEBOOK3: phf::Map<&'static str, CodeEntry> = phf_map! {
112+
// national activations
113+
"EAN" => (Phenomenon::NationalEmergency, SignificanceLevel::Warning),
114+
"NIC" => (Phenomenon::NationalInformationCenter, SignificanceLevel::Statement),
115+
116+
// tests
117+
"DMO" => (Phenomenon::PracticeDemoWarning, SignificanceLevel::Warning),
118+
"NAT" => (Phenomenon::NationalAudibleTest, SignificanceLevel::Test),
119+
"NPT" => (Phenomenon::NationalPeriodicTest, SignificanceLevel::Test),
120+
"NST" => (Phenomenon::NationalSilentTest, SignificanceLevel::Test),
121+
"RMT" => (Phenomenon::RequiredMonthlyTest, SignificanceLevel::Test),
122+
"RWT" => (Phenomenon::RequiredWeeklyTest, SignificanceLevel::Test),
123+
124+
// civil authority codes
125+
"ADR" => (Phenomenon::AdministrativeMessage, SignificanceLevel::Statement),
126+
"BLU" => (Phenomenon::BlueAlert, SignificanceLevel::Warning),
127+
"CAE" => (Phenomenon::ChildAbduction, SignificanceLevel::Emergency),
128+
"CDW" => (Phenomenon::CivilDanger, SignificanceLevel::Warning),
129+
"CEM" => (Phenomenon::CivilEmergency, SignificanceLevel::Warning),
130+
"EQW" => (Phenomenon::Earthquake, SignificanceLevel::Warning),
131+
"EVI" => (Phenomenon::Evacuation, SignificanceLevel::Warning),
132+
"FRW" => (Phenomenon::Fire, SignificanceLevel::Warning),
133+
"HMW" => (Phenomenon::HazardousMaterials, SignificanceLevel::Warning),
134+
"LAE" => (Phenomenon::LocalAreaEmergency, SignificanceLevel::Emergency),
135+
"LEW" => (Phenomenon::LawEnforcementWarning, SignificanceLevel::Warning),
136+
"NMN" => (Phenomenon::NetworkMessageNotification, SignificanceLevel::Statement),
137+
"NUW" => (Phenomenon::NuclearPowerPlant, SignificanceLevel::Warning),
138+
"RHW" => (Phenomenon::RadiologicalHazard, SignificanceLevel::Warning),
139+
"SPW" => (Phenomenon::ShelterInPlace, SignificanceLevel::Warning),
140+
"TOE" => (Phenomenon::TelephoneOutage, SignificanceLevel::Emergency),
141+
"VOW" => (Phenomenon::Volcano, SignificanceLevel::Warning),
142+
143+
// weather codes, three-character
144+
"HLS" => (Phenomenon::HurricaneLocalStatement, SignificanceLevel::Statement),
145+
"SPS" => (Phenomenon::SpecialWeatherStatement, SignificanceLevel::Statement),
146+
"SVR" => (Phenomenon::SevereThunderstorm, SignificanceLevel::Warning),
147+
"SVS" => (Phenomenon::SevereWeather, SignificanceLevel::Statement),
148+
"TOR" => (Phenomenon::Tornado, SignificanceLevel::Warning),
149+
150+
// "flash freeze warning" is Canada-only and not a NWS VTEC code
151+
"FSW" => (Phenomenon::FlashFreeze, SignificanceLevel::Warning),
152+
};
153+
154+
/// Database of two-character (plus significance) SAME codes
155+
///
156+
/// Two-character codes follow a standard convention set by
157+
/// the National Weather Service: the last character is the
158+
/// significance level.
159+
static CODEBOOK2: phf::Map<&'static str, Phenomenon> = phf_map! {
160+
// civil authority codes, two-character with standard significance
161+
"AV" => Phenomenon::Avalanche,
162+
163+
// weather codes, two-character with standard significance
164+
"BZ" => Phenomenon::Blizzard,
165+
"CF" => Phenomenon::CoastalFlood,
166+
"DS" => Phenomenon::DustStorm,
167+
"EW" => Phenomenon::ExtremeWind,
168+
"FF" => Phenomenon::FlashFlood,
169+
"FL" => Phenomenon::Flood,
170+
"FZ" => Phenomenon::Freeze,
171+
"HU" => Phenomenon::Hurricane,
172+
"HW" => Phenomenon::HighWind,
173+
"SM" => Phenomenon::SpecialMarine,
174+
"SQ" => Phenomenon::SnowSquall,
175+
"SS" => Phenomenon::StormSurge,
176+
"SV" => Phenomenon::SevereThunderstorm,
177+
"TO" => Phenomenon::Tornado,
178+
"TR" => Phenomenon::TropicalStorm,
179+
"TS" => Phenomenon::Tsunami,
180+
"WS" => Phenomenon::WinterStorm,
181+
};
182+
183+
/// Get codebook entry for full code like "`RWT`"
184+
fn lookup_threecharacter(code: &str) -> Option<CodeEntry> {
185+
CODEBOOK3.get(code.get(0..3)?).cloned()
186+
}
187+
188+
/// Convert `BZx` → `CodeEntry` with proper significance
189+
fn lookup_twocharacter(code: &str) -> Option<CodeEntry> {
190+
let phenom = CODEBOOK2.get(code.get(0..2)?).cloned()?;
191+
Some((phenom, code.get(2..3)?.into()))
192+
}
193+
194+
/// Convert `??x` → Unrecognized event with parsed significance
195+
fn lookup_onecharacter(code: &str) -> Option<CodeEntry> {
196+
Some((Phenomenon::Unrecognized, code.get(2..3)?.into()))
197+
}
198+
199+
#[cfg(test)]
200+
mod tests {
201+
use super::*;
202+
203+
use std::collections::HashSet;
204+
205+
use lazy_static::lazy_static;
206+
use regex::Regex;
207+
use strum::IntoEnumIterator;
208+
209+
/// ensure we have populated our codebooks correctly
210+
#[test]
211+
fn check_codebooks() {
212+
lazy_static! {
213+
static ref ASCII_UPPER: Regex = Regex::new(r"^[[A-Z]]{2,3}$").expect("bad test regexp");
214+
}
215+
216+
let mut codebook_phenomenon = HashSet::new();
217+
218+
for (key, val) in CODEBOOK3.entries() {
219+
assert!(key.is_ascii());
220+
assert_eq!(key.len(), 3);
221+
ASCII_UPPER.is_match(key);
222+
assert_ne!(Phenomenon::Unrecognized, val.0);
223+
assert_ne!(SignificanceLevel::Unknown, val.1);
224+
codebook_phenomenon.insert(val.0);
225+
}
226+
227+
for (key, val) in CODEBOOK2.entries() {
228+
assert!(key.is_ascii());
229+
assert_eq!(key.len(), 2);
230+
ASCII_UPPER.is_match(key);
231+
assert_ne!(&Phenomenon::Unrecognized, val);
232+
codebook_phenomenon.insert(*val);
233+
}
234+
235+
// check that every Phenomenon is covered by at least one codebook entry
236+
for phen in Phenomenon::iter() {
237+
if phen.is_unrecognized() {
238+
continue;
239+
}
240+
241+
assert!(
242+
codebook_phenomenon.contains(&phen),
243+
"phenomenon {} not covered by any codebook entries",
244+
phen
245+
);
246+
}
247+
}
248+
}

0 commit comments

Comments
 (0)