use std::path::PathBuf;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value};
pub const API_VERSION: u32 = 1;
pub const EXIT_CONFIG: i32 = 78;
pub mod topic {
pub const CALL_START: &str = "call.start";
pub const CALL_END: &str = "call.end";
pub const CALL_CONCLUDED: &str = "call.concluded";
pub const UNIT: &str = "unit";
pub const AUDIO: &str = "audio";
pub const STATUS: &str = "status";
pub const TONE_PATTERN: &str = "tone_pattern";
pub const ALL: &[&str] = &[CALL_START, CALL_END, CALL_CONCLUDED, UNIT, AUDIO, STATUS, TONE_PATTERN];
}
pub mod format {
pub const M4A: &str = "m4a";
pub const WAV: &str = "wav";
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Manifest {
pub id: String,
pub name: String,
pub version: String,
pub description: String,
pub api: u32,
pub subscribe: Vec<String>,
pub audio_formats: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub config: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_config: Option<Value>,
#[serde(skip_serializing_if = "String::is_empty")]
pub homepage: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub repository: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub authors: Vec<String>,
#[serde(skip_serializing_if = "String::is_empty")]
pub license: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub page: Option<Page>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub contribute: Vec<Contribution>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub live_settings: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Page {
#[serde(skip_serializing_if = "String::is_empty")]
pub title: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Contribution {
pub on: String,
pub label: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub hint: String,
pub append: String,
pub item: Value,
}
impl Manifest {
pub fn subscribes(&self, topic: &str) -> bool {
self.subscribe.iter().any(|t| t == topic)
}
pub fn wants_format(&self, format: &str) -> bool {
self.audio_formats.iter().any(|f| f == format)
}
}
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum HostMessage {
#[serde(rename = "hello")]
Hello(Hello),
#[serde(rename = "call.start")]
CallStart(CallInfo),
#[serde(rename = "call.end")]
CallEnd(CallInfo),
#[serde(rename = "call.concluded")]
CallConcluded(ConcludedCall),
#[serde(rename = "unit")]
Unit(UnitEvent),
#[serde(rename = "audio")]
Audio(AudioChunk),
#[serde(rename = "status")]
Status(Status),
#[serde(rename = "tone_pattern")]
TonePattern(TonePatternEvent),
#[serde(rename = "action")]
Action(Action),
#[serde(rename = "settings")]
Settings(Hello),
#[serde(rename = "shutdown")]
Shutdown(Shutdown),
#[serde(other)]
Unknown,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Hello {
pub api: u32,
pub host: HostInfo,
pub config: Value,
pub systems: Vec<SystemInfo>,
pub capture_dir: PathBuf,
pub data_dir: PathBuf,
pub audio_formats: Vec<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct HostInfo {
pub name: String,
pub version: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SystemInfo {
pub index: u16,
pub short_name: String,
pub kind: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub name: String,
#[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub fcc: std::collections::BTreeMap<String, FccLicense>,
#[serde(skip_serializing_if = "String::is_empty")]
pub fcc_call_sign: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub fcc_name: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub sites: Vec<SiteInfo>,
pub config: Value,
#[serde(skip_serializing_if = "Value::is_null")]
pub settings: Value,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct SiteInfo {
pub name: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct FccLicense {
pub call_sign: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub name: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct CallInfo {
pub id: u32,
pub system: u16,
pub short_name: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub site: String,
pub talkgroup: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub talkgroup_ota: Option<u32>,
pub talkgroup_tag: String,
pub freq_hz: u64,
pub tdma_slot: Option<u8>,
pub analog: bool,
pub encrypted: bool,
pub algid: Option<u8>,
pub key_id: Option<u16>,
pub decrypted: bool,
pub emergency: bool,
pub duplex: bool,
pub packet: bool,
pub priority: u8,
pub recording: bool,
pub reason: Option<String>,
pub start_time: f64,
pub units: Vec<u32>,
pub sources: Vec<CallSource>,
pub patched_talkgroups: Vec<u32>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CallSource {
pub unit: u32,
#[serde(skip_serializing_if = "String::is_empty")]
pub tag: String,
pub time: f64,
pub emergency: bool,
pub encrypted: bool,
pub duplex: bool,
pub packet: bool,
pub priority: u8,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ConcludedCall {
pub path: String,
pub system: u16,
pub call: CallRecord,
pub files: CallFiles,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct CallFiles {
pub json: PathBuf,
pub wav: PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
pub m4a: Option<PathBuf>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CallRecord {
pub call_num: u64,
#[serde(skip_serializing_if = "String::is_empty")]
pub transmission_id: String,
pub freq: u64,
pub freq_error: i64,
#[serde(serialize_with = "tenth_or_null")]
pub signal: Option<f64>,
#[serde(serialize_with = "tenth_or_null")]
pub noise: Option<f64>,
#[serde(serialize_with = "tenth_or_null")]
pub snr: Option<f64>,
#[serde(serialize_with = "tenth_or_null")]
pub clean_voice_pct: Option<f64>,
pub source_num: i64,
pub recorder_num: i64,
pub tdma_slot: i64,
#[serde(deserialize_with = "flag", serialize_with = "flag_out")]
pub phase2_tdma: bool,
pub start_time: i64,
pub stop_time: i64,
pub start_time_ms: i64,
pub stop_time_ms: i64,
#[serde(deserialize_with = "flag", serialize_with = "flag_out")]
pub emergency: bool,
pub priority: i64,
#[serde(deserialize_with = "flag", serialize_with = "flag_out")]
pub mode: bool,
#[serde(deserialize_with = "flag", serialize_with = "flag_out")]
pub duplex: bool,
#[serde(deserialize_with = "flag", serialize_with = "flag_out")]
pub encrypted: bool,
pub algid: i64,
pub key_id: i64,
#[serde(deserialize_with = "flag", serialize_with = "flag_out")]
pub decrypted: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub clock_off: bool,
#[serde(serialize_with = "plain")]
pub call_length: f64,
pub call_length_ms: i64,
pub talkgroup: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub talkgroup_ota: Option<u32>,
pub talkgroup_tag: String,
pub talkgroup_description: String,
pub talkgroup_group_tag: String,
pub talkgroup_group: String,
pub nac: String,
pub color_code: i64,
pub ran: i64,
pub tone_mode: String,
pub tone_detected: String,
#[serde(serialize_with = "thousandth")]
pub tone_confidence: f64,
pub audio_type: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub system_type: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub voice_mode: String,
pub short_name: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub site: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub patched_talkgroups: Vec<u32>,
#[serde(rename = "freqList")]
pub freq_list: Vec<FreqEntry>,
#[serde(rename = "errorList")]
pub error_list: Vec<ErrorInterval>,
#[serde(rename = "srcList")]
pub src_list: Vec<SrcEntry>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tones: Vec<ToneEntry>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tone_patterns: Vec<TonePattern>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub signals: Vec<String>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
impl Default for CallRecord {
fn default() -> Self {
CallRecord {
call_num: 0,
transmission_id: String::new(),
freq: 0,
freq_error: 0,
signal: None,
noise: None,
snr: None,
clean_voice_pct: None,
source_num: 0,
recorder_num: 0,
tdma_slot: 0,
phase2_tdma: false,
start_time: 0,
stop_time: 0,
start_time_ms: 0,
stop_time_ms: 0,
emergency: false,
priority: 0,
mode: false,
duplex: false,
encrypted: false,
algid: -1,
key_id: -1,
decrypted: false,
clock_off: false,
call_length: 0.0,
call_length_ms: 0,
talkgroup: 0,
talkgroup_ota: None,
talkgroup_tag: String::new(),
talkgroup_description: String::new(),
talkgroup_group_tag: String::new(),
talkgroup_group: String::new(),
nac: String::new(),
color_code: -1,
ran: -1,
tone_mode: "off".into(),
tone_detected: String::new(),
tone_confidence: 0.0,
audio_type: "digital".into(),
system_type: String::new(),
voice_mode: String::new(),
short_name: String::new(),
site: String::new(),
patched_talkgroups: Vec::new(),
freq_list: Vec::new(),
error_list: Vec::new(),
src_list: Vec::new(),
tones: Vec::new(),
tone_patterns: Vec::new(),
signals: Vec::new(),
extra: Map::new(),
}
}
}
impl CallRecord {
pub fn error_count(&self) -> u64 {
self.freq_list.iter().map(|f| f.error_count).sum()
}
pub fn spike_count(&self) -> u64 {
self.freq_list.iter().map(|f| f.spike_count).sum()
}
pub fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_default()
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct FreqEntry {
pub freq: u64,
pub time: i64,
#[serde(serialize_with = "plain")]
pub pos: f64,
#[serde(serialize_with = "plain")]
pub len: f64,
pub error_count: u64,
pub spike_count: u64,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ErrorInterval {
#[serde(serialize_with = "plain")]
pub pos: f64,
#[serde(serialize_with = "plain")]
pub len: f64,
pub frames: u64,
pub error_count: u64,
pub bad_frames: u64,
pub max_frame_errors: u64,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct SrcEntry {
pub src: i64,
pub time: i64,
#[serde(serialize_with = "plain")]
pub pos: f64,
#[serde(deserialize_with = "flag", serialize_with = "flag_out")]
pub emergency: bool,
#[serde(deserialize_with = "flag", serialize_with = "flag_out")]
pub encrypted: bool,
#[serde(deserialize_with = "flag", serialize_with = "flag_out")]
pub duplex: bool,
#[serde(deserialize_with = "flag", serialize_with = "flag_out")]
pub mode: bool,
pub priority: i64,
pub signal_system: String,
pub tag: String,
pub tag_ota: String,
#[serde(skip_serializing_if = "Option::is_none", serialize_with = "millionth")]
pub lat: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", serialize_with = "millionth")]
pub lon: Option<f64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ToneEntry {
#[serde(rename = "type")]
pub kind: String,
pub value: String,
#[serde(serialize_with = "plain")]
pub pos: f64,
#[serde(serialize_with = "plain")]
pub len: f64,
pub src: i64,
pub origin: String,
#[serde(skip_serializing_if = "Option::is_none", serialize_with = "tenth_or_null")]
pub level: Option<f64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct TonePattern {
pub pattern: String,
pub tones: Vec<f64>,
pub lengths: Vec<f64>,
#[serde(skip_serializing_if = "String::is_empty")]
pub digits: String,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub knox: bool,
#[serde(skip_serializing_if = "is_zero")]
pub cycles: u32,
#[serde(skip_serializing_if = "String::is_empty")]
pub format: String,
#[serde(serialize_with = "plain")]
pub pos: f64,
#[serde(serialize_with = "plain")]
pub len: f64,
pub src: i64,
pub origin: String,
pub text: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub signals: Vec<String>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
fn is_zero(v: &u32) -> bool {
*v == 0
}
fn number<S: serde::Serializer>(text: String, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::Error;
serde_json::value::RawValue::from_string(text).map_err(S::Error::custom)?.serialize(s)
}
fn finite<S: serde::Serializer>(v: f64, text: impl FnOnce(f64) -> String, s: S) -> Result<S::Ok, S::Error> {
if v.is_finite() { number(text(v), s) } else { s.serialize_none() }
}
fn flag_out<S: serde::Serializer>(v: &bool, s: S) -> Result<S::Ok, S::Error> {
s.serialize_u8(*v as u8)
}
fn plain<S: serde::Serializer>(v: &f64, s: S) -> Result<S::Ok, S::Error> {
finite(*v, |v| format!("{v}"), s)
}
fn thousandth<S: serde::Serializer>(v: &f64, s: S) -> Result<S::Ok, S::Error> {
finite(*v, |v| format!("{v:.3}"), s)
}
fn tenth_or_null<S: serde::Serializer>(v: &Option<f64>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(v) => finite(*v, |v| format!("{v:.1}"), s),
None => s.serialize_none(),
}
}
fn millionth<S: serde::Serializer>(v: &Option<f64>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(v) => finite(*v, |v| format!("{v:.6}"), s),
None => s.serialize_none(),
}
}
fn flag<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
Ok(match Value::deserialize(d)? {
Value::Bool(b) => b,
Value::Number(n) => n.as_f64().is_some_and(|v| v != 0.0),
Value::String(s) => s == "true" || s == "1",
_ => false,
})
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct TonePatternEvent {
pub short_name: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub site: String,
pub call_num: u64,
pub talkgroup: u32,
pub alpha_tag: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub src_tag: String,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub decrypted: bool,
pub time: f64,
#[serde(flatten)]
pub pattern: TonePattern,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct UnitEvent {
pub system: u16,
pub short_name: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub site: String,
pub kind: String,
pub unit: u32,
#[serde(skip_serializing_if = "String::is_empty")]
pub tag: String,
pub talkgroup: Option<u32>,
#[serde(skip_serializing_if = "String::is_empty")]
pub alpha_tag: String,
pub emergency: bool,
pub time: f64,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct AudioChunk {
pub call_id: u32,
pub system: u16,
pub short_name: String,
pub talkgroup: u32,
pub sample_rate: u32,
pub pcm: String,
}
impl AudioChunk {
pub fn new(call_id: u32, system: u16, talkgroup: u32, sample_rate: u32, samples: &[i16]) -> Self {
let bytes: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
AudioChunk { call_id, system, talkgroup, sample_rate, pcm: base64::encode(&bytes), ..Default::default() }
}
pub fn samples(&self) -> Vec<i16> {
let b = base64::decode(&self.pcm);
b.chunks_exact(2).map(|c| i16::from_le_bytes([c[0], c[1]])).collect()
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Status {
pub time: f64,
pub systems: Vec<SystemStatus>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SystemStatus {
pub index: u16,
pub short_name: String,
pub site_name: String,
pub control_channel_hz: Option<u64>,
pub decode_rate: f64,
pub active_calls: u32,
pub recording: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub nac: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub wacn: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sys_id: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rfss: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub site: Option<u32>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Shutdown {
pub grace_s: f64,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Action {
pub request: u64,
pub action: String,
pub value: Value,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum PluginMessage {
#[serde(rename = "ready")]
Ready,
#[serde(rename = "log")]
Log { level: Level, message: String },
#[serde(rename = "status")]
Status {
state: State,
#[serde(default)]
message: String,
},
#[serde(rename = "call.result")]
CallResult {
path: String,
outcome: Outcome,
#[serde(default, skip_serializing_if = "String::is_empty")]
message: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
took_ms: Option<u64>,
},
#[serde(rename = "metrics")]
Metrics(Metrics),
#[serde(rename = "action.result")]
ActionResult {
request: u64,
ok: bool,
#[serde(default, skip_serializing_if = "String::is_empty")]
message: String,
},
#[serde(other)]
Unknown,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(default, rename_all = "camelCase")]
pub struct Metrics {
#[serde(skip_serializing_if = "Option::is_none")]
pub queued: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retrying: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub in_flight: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retries: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes_sent: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub latency_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_ok: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_error: Option<f64>,
#[serde(skip_serializing_if = "String::is_empty")]
pub last_error_text: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub endpoints: Vec<Endpoint>,
#[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub extra: std::collections::BTreeMap<String, serde_json::Value>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(default, rename_all = "camelCase")]
pub struct Endpoint {
pub name: String,
pub state: EndpointState,
#[serde(skip_serializing_if = "Option::is_none")]
pub latency_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_ok: Option<f64>,
#[serde(skip_serializing_if = "String::is_empty")]
pub last_error: String,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum EndpointState {
#[default]
Unknown,
Up,
Degraded,
Down,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "lowercase")]
pub enum Level {
Error,
Warn,
Info,
Debug,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum State {
Ok,
Warning,
Error,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Outcome {
Ok,
Skipped,
Failed,
}
pub mod base64 {
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
pub fn encode(b: &[u8]) -> String {
let mut o = String::with_capacity(b.len().div_ceil(3) * 4);
for c in b.chunks(3) {
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
for i in 0..4 {
if i <= c.len() {
o.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
} else {
o.push('=');
}
}
}
o
}
pub fn decode(s: &str) -> Vec<u8> {
let mut o = Vec::with_capacity(s.len() / 4 * 3);
let (mut acc, mut bits) = (0u32, 0);
for ch in s.bytes() {
let v = match ch {
b'A'..=b'Z' => ch - b'A',
b'a'..=b'z' => ch - b'a' + 26,
b'0'..=b'9' => ch - b'0' + 52,
b'+' => 62,
b'/' => 63,
_ => continue,
};
acc = acc << 6 | v as u32;
bits += 6;
if bits >= 8 {
bits -= 8;
o.push((acc >> bits) as u8);
}
}
o
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_call_record_round_trips_as_the_recorders_files_are() {
let tr = r#"{"freq":851012500,"signal":-31.24,"snr":null,"emergency":true,"encrypted":false,"call_length":4,"talkgroup":101,"short_name":"dcfd",
"srcList":[{"src":7,"pos":1.5,"emergency":false,"lat":30.0529}],"freqList":[{"freq":851012500,"len":4.0}],"rec_state_type":"STOPPED"}"#;
let r: CallRecord = serde_json::from_str(tr).unwrap();
assert!(r.emergency && !r.encrypted && r.signal == Some(-31.24) && r.snr.is_none());
let back = r.to_json();
assert!(back.contains("\"signal\":-31.2,\"noise\":null,\"snr\":null,"), "{back}");
assert!(back.contains("\"emergency\":1,") && back.contains("\"encrypted\":0,"), "{back}");
assert!(back.contains("\"call_length\":4,") && back.contains("\"len\":4,"), "{back}");
assert!(back.contains("\"pos\":1.5,\"emergency\":0,") && back.contains("\"lat\":30.052900"), "{back}");
assert!(back.ends_with("\"rec_state_type\":\"STOPPED\"}"), "{back}");
assert_eq!(serde_json::from_str::<CallRecord>(&back).unwrap().to_json(), back);
}
#[test]
fn base64_roundtrip() {
for n in 0..10 {
let b: Vec<u8> = (0..n).map(|i| (i * 37 + 5) as u8).collect();
assert_eq!(base64::decode(&base64::encode(&b)), b);
}
assert_eq!(base64::encode(b"hello"), "aGVsbG8=");
}
#[test]
fn unknown_types_and_fields_are_ignored() {
let m: HostMessage = serde_json::from_str(r#"{"type":"from.the.future","x":1}"#).unwrap();
assert!(matches!(m, HostMessage::Unknown));
let m: HostMessage = serde_json::from_str(r#"{"type":"shutdown","grace_s":5,"new_field":true}"#).unwrap();
assert!(matches!(m, HostMessage::Shutdown(Shutdown { grace_s }) if grace_s == 5.0));
}
#[test]
fn call_record_reads_trunk_recorder_json() {
let j = r#"{"call_num":7,"freq":851012500,"start_time":1700000000,"stop_time":1700000005,"emergency":0,"encrypted":1,
"call_length":5,"talkgroup":101,"talkgroup_tag":"Fire Disp","audio_type":"digital","short_name":"dcfd","phase2_tdma":0,
"freqList":[{"freq":851012500,"time":1700000000,"pos":0,"len":5,"error_count":3,"spike_count":1}],
"srcList":[{"src":1234,"time":1700000000,"pos":0.5,"emergency":0,"signal_system":"","tag":"","tag_ota":"E1"}],
"color_code":-1,"patched_talkgroups":[101,65001]}"#;
let c: CallRecord = serde_json::from_str(j).unwrap();
assert!(c.encrypted && !c.emergency);
assert_eq!(c.patched_talkgroups, [101, 65001]);
assert_eq!((c.error_count(), c.spike_count()), (3, 1));
assert_eq!(c.src_list[0].tag_ota, "E1");
assert_eq!(c.color_code, -1);
let v = serde_json::to_value(&c).unwrap();
assert_eq!(v["color_code"], -1);
assert_eq!(v["srcList"][0]["src"], 1234);
assert_eq!(v["patched_talkgroups"], serde_json::json!([101, 65001]));
let unpatched = serde_json::to_value(CallRecord::default()).unwrap();
assert!(unpatched.get("patched_talkgroups").is_none());
}
#[test]
fn system_identity_is_there_once_stated() {
let s: SystemStatus = serde_json::from_str(r#"{"index":0,"short_name":"county","decode_rate":39.8}"#).unwrap();
assert_eq!((s.nac, s.wacn, s.sys_id, s.rfss, s.site), (None, None, None, None, None));
let v = serde_json::to_value(&s).unwrap();
assert!(["nac", "wacn", "sys_id", "rfss", "site"].iter().all(|k| v.get(k).is_none()), "{v}");
let s = SystemStatus { nac: Some(0x3A0), wacn: Some(0xBEE00), sys_id: Some(0x3AB), rfss: Some(1), site: Some(5), ..s };
let v = serde_json::to_value(&s).unwrap();
assert_eq!((v["nac"].as_u64(), v["wacn"].as_u64(), v["site"].as_u64()), (Some(0x3A0), Some(0xBEE00), Some(5)));
}
}