use ringo_core::account::{Account, BackendOptions};
use ringo_core::event::{AppEvent, InviteHeaders, MediaStats};
use serde::{Deserialize, Serialize};
use std::io::{self, Read, Write};
pub(crate) const FRAME_CONTROL: u8 = 0;
pub(crate) const FRAME_AUDIO: u8 = 1;
const MAX_FRAME_LEN: usize = 16 * 1024 * 1024;
pub(crate) fn write_frame<W: Write>(w: &mut W, kind: u8, payload: &[u8]) -> io::Result<()> {
if payload.len() > MAX_FRAME_LEN {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"frame too large",
));
}
w.write_all(&[kind])?;
w.write_all(&(payload.len() as u32).to_le_bytes())?;
w.write_all(payload)?;
w.flush()
}
pub(crate) fn read_frame<R: Read>(r: &mut R) -> io::Result<Option<(u8, Vec<u8>)>> {
let mut hdr = [0u8; 5];
let mut filled = 0;
while filled < hdr.len() {
match r.read(&mut hdr[filled..]) {
Ok(0) if filled == 0 => return Ok(None), Ok(0) => {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"EOF in frame header",
));
}
Ok(n) => filled += n,
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
let kind = hdr[0];
let len = u32::from_le_bytes([hdr[1], hdr[2], hdr[3], hdr[4]]) as usize;
if len > MAX_FRAME_LEN {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"frame too large",
));
}
let mut payload = vec![0u8; len];
r.read_exact(&mut payload)?;
Ok(Some((kind, payload)))
}
pub(crate) fn pcm_to_bytes(samples: &[i16]) -> Vec<u8> {
let mut v = Vec::with_capacity(samples.len() * 2);
for &s in samples {
v.extend_from_slice(&s.to_le_bytes());
}
v
}
pub(crate) fn bytes_to_pcm(bytes: &[u8]) -> Vec<i16> {
bytes
.chunks_exact(2)
.map(|c| i16::from_le_bytes([c[0], c[1]]))
.collect()
}
pub(crate) const PROTO_VERSION: u32 = 2;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Handshake {
pub proto_version: u32,
pub config: AgentConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AgentConfig {
pub name: String,
pub account: Account,
pub options: BackendOptions,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "cmd", rename_all = "snake_case")]
pub enum Command {
Register {
aor: String,
regint: u32,
},
Dial {
number: String,
},
Hangup,
HangupAll,
Accept,
Hold,
Resume,
Mute,
Dtmf {
digit: char,
},
SwitchLine {
line: usize,
},
Transfer {
uri: String,
},
AttendedTransferStart {
uri: String,
},
AttendedTransferExec,
AttendedTransferAbort,
AddHeader {
key: String,
value: String,
},
RmHeader {
key: String,
},
SetAudioSource {
spec: String,
},
SetRecording {
on: bool,
},
ArmInviteResponse {
scode: u16,
reason: String,
headers: Vec<String>,
},
DisarmInviteResponse,
StartTxAudio {
rate: u32,
},
StartRxAudio,
Shutdown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "query", rename_all = "snake_case")]
pub enum Query {
MediaStats,
ReceivedDtmf,
SaveAudio { prefix: String },
CallCount,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryEnvelope {
pub id: u64,
#[serde(flatten)]
pub query: Query,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ToWorker {
Cmd(Command),
Query(QueryEnvelope),
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct WireMediaStats {
pub rtt_ms: f64,
pub jitter_ms: f64,
pub rx_lost: i32,
pub packet_loss_pct: f64,
pub mos: f64,
}
impl From<MediaStats> for WireMediaStats {
fn from(s: MediaStats) -> Self {
Self {
rtt_ms: s.rtt_ms,
jitter_ms: s.jitter_ms,
rx_lost: s.rx_lost,
packet_loss_pct: s.packet_loss_pct,
mos: s.mos,
}
}
}
impl From<WireMediaStats> for MediaStats {
fn from(s: WireMediaStats) -> Self {
Self {
rtt_ms: s.rtt_ms,
jitter_ms: s.jitter_ms,
rx_lost: s.rx_lost,
packet_loss_pct: s.packet_loss_pct,
mos: s.mos,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WireSavedAudio {
pub path: String,
pub kind: String,
pub samples: usize,
pub srate: u32,
pub truncated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReplyResult {
MediaStats(Option<WireMediaStats>),
Dtmf(String),
SavedAudio(Vec<WireSavedAudio>),
CallCount(u32),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Reply {
pub reply: u64,
pub result: ReplyResult,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Headers {
pub headers: InviteHeaders,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ready {
pub ready: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RxAudioStarted {
pub rx_audio_rate: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum FromWorker {
Ready(Ready),
Event(WireEvent),
Reply(Reply),
Headers(Headers),
RxAudioStarted(RxAudioStarted),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum WireEvent {
Registering {
account: String,
},
RegisterOk {
account: String,
},
RegisterFailed {
reason: String,
},
Unregistered {
account: String,
},
CallIncoming {
call_id: String,
number: String,
display_name: Option<String>,
},
CallOutgoing {
call_id: String,
number: String,
},
CallRinging {
call_id: String,
},
CallEstablished {
call_id: String,
},
CallClosed {
call_id: String,
reason: String,
error: bool,
},
CallDeflected {
from: String,
display_name: Option<String>,
target: String,
},
CallHold {
call_id: String,
},
CallResume {
call_id: String,
},
CallTransferFailed {
call_id: String,
},
VoicemailStatus {
waiting: bool,
new_count: u32,
},
Response {
ok: bool,
data: String,
},
Unknown {
class: String,
type_: String,
},
BackendConnectFailed {
reason: String,
},
}
impl From<&AppEvent> for WireEvent {
fn from(e: &AppEvent) -> Self {
match e {
AppEvent::Registering { account } => Self::Registering {
account: account.clone(),
},
AppEvent::RegisterOk { account } => Self::RegisterOk {
account: account.clone(),
},
AppEvent::RegisterFailed { reason } => Self::RegisterFailed {
reason: reason.clone(),
},
AppEvent::Unregistered { account } => Self::Unregistered {
account: account.clone(),
},
AppEvent::CallIncoming {
call_id,
number,
display_name,
} => Self::CallIncoming {
call_id: call_id.clone(),
number: number.clone(),
display_name: display_name.clone(),
},
AppEvent::CallOutgoing { call_id, number } => Self::CallOutgoing {
call_id: call_id.clone(),
number: number.clone(),
},
AppEvent::CallRinging { call_id } => Self::CallRinging {
call_id: call_id.clone(),
},
AppEvent::CallEstablished { call_id } => Self::CallEstablished {
call_id: call_id.clone(),
},
AppEvent::CallClosed {
call_id,
reason,
error,
} => Self::CallClosed {
call_id: call_id.clone(),
reason: reason.clone(),
error: *error,
},
AppEvent::CallHold { call_id } => Self::CallHold {
call_id: call_id.clone(),
},
AppEvent::CallResume { call_id } => Self::CallResume {
call_id: call_id.clone(),
},
AppEvent::CallTransferFailed { call_id } => Self::CallTransferFailed {
call_id: call_id.clone(),
},
AppEvent::CallDeflected {
from,
display_name,
target,
} => Self::CallDeflected {
from: from.clone(),
display_name: display_name.clone(),
target: target.clone(),
},
AppEvent::VoicemailStatus { waiting, new_count } => Self::VoicemailStatus {
waiting: *waiting,
new_count: *new_count,
},
AppEvent::Response { ok, data } => Self::Response {
ok: *ok,
data: data.clone(),
},
AppEvent::Unknown { class, type_ } => Self::Unknown {
class: class.clone(),
type_: type_.clone(),
},
AppEvent::BackendConnectFailed { reason } => Self::BackendConnectFailed {
reason: reason.clone(),
},
}
}
}
impl From<WireEvent> for AppEvent {
fn from(w: WireEvent) -> Self {
match w {
WireEvent::Registering { account } => AppEvent::Registering { account },
WireEvent::RegisterOk { account } => AppEvent::RegisterOk { account },
WireEvent::RegisterFailed { reason } => AppEvent::RegisterFailed { reason },
WireEvent::Unregistered { account } => AppEvent::Unregistered { account },
WireEvent::CallIncoming {
call_id,
number,
display_name,
} => AppEvent::CallIncoming {
call_id,
number,
display_name,
},
WireEvent::CallOutgoing { call_id, number } => {
AppEvent::CallOutgoing { call_id, number }
}
WireEvent::CallRinging { call_id } => AppEvent::CallRinging { call_id },
WireEvent::CallEstablished { call_id } => AppEvent::CallEstablished { call_id },
WireEvent::CallClosed {
call_id,
reason,
error,
} => AppEvent::CallClosed {
call_id,
reason,
error,
},
WireEvent::CallHold { call_id } => AppEvent::CallHold { call_id },
WireEvent::CallResume { call_id } => AppEvent::CallResume { call_id },
WireEvent::CallTransferFailed { call_id } => AppEvent::CallTransferFailed { call_id },
WireEvent::CallDeflected {
from,
display_name,
target,
} => AppEvent::CallDeflected {
from,
display_name,
target,
},
WireEvent::VoicemailStatus { waiting, new_count } => {
AppEvent::VoicemailStatus { waiting, new_count }
}
WireEvent::Response { ok, data } => AppEvent::Response { ok, data },
WireEvent::Unknown { class, type_ } => AppEvent::Unknown { class, type_ },
WireEvent::BackendConnectFailed { reason } => AppEvent::BackendConnectFailed { reason },
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_roundtrips_as_to_worker() {
let line = serde_json::to_string(&ToWorker::Cmd(Command::Dial {
number: "sip:1@example.com".into(),
}))
.unwrap();
assert_eq!(line, r#"{"cmd":"dial","number":"sip:1@example.com"}"#);
assert!(matches!(
serde_json::from_str::<ToWorker>(&line).unwrap(),
ToWorker::Cmd(Command::Dial { .. })
));
}
#[test]
fn query_envelope_roundtrips_as_to_worker() {
let line = serde_json::to_string(&ToWorker::Query(QueryEnvelope {
id: 7,
query: Query::SaveAudio {
prefix: "rec".into(),
},
}))
.unwrap();
let back: ToWorker = serde_json::from_str(&line).unwrap();
match back {
ToWorker::Query(QueryEnvelope {
id,
query: Query::SaveAudio { prefix },
}) => {
assert_eq!((id, prefix.as_str()), (7, "rec"));
}
_ => panic!("expected save_audio query"),
}
}
#[test]
fn event_and_reply_disambiguate_as_from_worker() {
let ev = serde_json::to_string(&FromWorker::Event(WireEvent::RegisterOk {
account: "a".into(),
}))
.unwrap();
assert!(matches!(
serde_json::from_str::<FromWorker>(&ev).unwrap(),
FromWorker::Event(WireEvent::RegisterOk { .. })
));
let rep = serde_json::to_string(&FromWorker::Reply(Reply {
reply: 7,
result: ReplyResult::CallCount(2),
}))
.unwrap();
match serde_json::from_str::<FromWorker>(&rep).unwrap() {
FromWorker::Reply(Reply {
reply,
result: ReplyResult::CallCount(n),
}) => {
assert_eq!((reply, n), (7, 2));
}
_ => panic!("expected call_count reply"),
}
}
#[test]
fn app_event_mirrors_both_ways() {
let ev = AppEvent::CallIncoming {
call_id: "abc".into(),
number: "sip:1@x".into(),
display_name: Some("Bob".into()),
};
let line = serde_json::to_string(&WireEvent::from(&ev)).unwrap();
let back: AppEvent = serde_json::from_str::<WireEvent>(&line).unwrap().into();
assert!(matches!(back, AppEvent::CallIncoming { .. }));
}
#[test]
fn ready_disambiguates_from_an_event() {
let ready = serde_json::to_string(&FromWorker::Ready(Ready { ready: true })).unwrap();
assert!(matches!(
serde_json::from_str::<FromWorker>(&ready).unwrap(),
FromWorker::Ready(Ready { ready: true })
));
let ev = serde_json::to_string(&FromWorker::Event(WireEvent::RegisterOk {
account: "a".into(),
}))
.unwrap();
assert!(matches!(
serde_json::from_str::<FromWorker>(&ev).unwrap(),
FromWorker::Event(_)
));
}
#[test]
fn handshake_carries_version_and_config() {
let mut cfg = AgentConfig {
name: "A".into(),
..Default::default()
};
cfg.account.username = "alice".into();
let hs = Handshake {
proto_version: PROTO_VERSION,
config: cfg,
};
let line = serde_json::to_string(&hs).unwrap();
let back: Handshake = serde_json::from_str(&line).unwrap();
assert_eq!(back.proto_version, PROTO_VERSION);
assert_eq!(back.config.account.username, "alice");
}
#[test]
fn agent_config_roundtrips_with_embedded_account() {
let mut cfg = AgentConfig {
name: "A".into(),
..Default::default()
};
cfg.account.username = "alice".into();
cfg.account.catchall = true;
cfg.options.audio_driver = Some("aubridge".into());
let line = serde_json::to_string(&cfg).unwrap();
let back: AgentConfig = serde_json::from_str(&line).unwrap();
assert_eq!(back.name, "A");
assert_eq!(back.account.username, "alice");
assert!(back.account.catchall);
assert_eq!(back.options.audio_driver.as_deref(), Some("aubridge"));
}
#[test]
fn frames_roundtrip_control_then_audio() {
let mut buf: Vec<u8> = Vec::new();
write_frame(&mut buf, FRAME_CONTROL, b"{\"cmd\":\"accept\"}").unwrap();
write_frame(&mut buf, FRAME_AUDIO, &pcm_to_bytes(&[1, -2, 3])).unwrap();
let mut r = &buf[..];
let (k1, p1) = read_frame(&mut r).unwrap().unwrap();
assert_eq!(k1, FRAME_CONTROL);
assert_eq!(p1, b"{\"cmd\":\"accept\"}");
let (k2, p2) = read_frame(&mut r).unwrap().unwrap();
assert_eq!(k2, FRAME_AUDIO);
assert_eq!(bytes_to_pcm(&p2), vec![1, -2, 3]);
assert!(read_frame(&mut r).unwrap().is_none()); }
#[test]
fn audio_frame_can_contain_newline_bytes() {
let samples = vec![0x0A0Ai16, 0x000A, -1];
let mut buf = Vec::new();
write_frame(&mut buf, FRAME_AUDIO, &pcm_to_bytes(&samples)).unwrap();
let (_k, p) = read_frame(&mut &buf[..]).unwrap().unwrap();
assert_eq!(bytes_to_pcm(&p), samples);
}
#[test]
fn rx_audio_started_disambiguates_from_event_and_reply() {
let started = serde_json::to_string(&FromWorker::RxAudioStarted(RxAudioStarted {
rx_audio_rate: 8000,
}))
.unwrap();
assert!(matches!(
serde_json::from_str::<FromWorker>(&started).unwrap(),
FromWorker::RxAudioStarted(RxAudioStarted {
rx_audio_rate: 8000
})
));
let ev = serde_json::to_string(&FromWorker::Event(WireEvent::RegisterOk {
account: "a".into(),
}))
.unwrap();
let rep = serde_json::to_string(&FromWorker::Reply(Reply {
reply: 1,
result: ReplyResult::CallCount(0),
}))
.unwrap();
assert!(matches!(
serde_json::from_str::<FromWorker>(&ev).unwrap(),
FromWorker::Event(_)
));
assert!(matches!(
serde_json::from_str::<FromWorker>(&rep).unwrap(),
FromWorker::Reply(_)
));
}
#[test]
fn read_frame_distinguishes_clean_eof_from_torn_header() {
assert!(read_frame(&mut &b""[..]).unwrap().is_none());
let err = read_frame(&mut &[FRAME_CONTROL, 0x01][..]).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
}
#[test]
fn read_frame_rejects_oversized_length() {
let mut buf = vec![FRAME_AUDIO];
buf.extend_from_slice(&(MAX_FRAME_LEN as u32 + 1).to_le_bytes());
let err = read_frame(&mut &buf[..]).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn read_frame_errors_on_truncated_payload() {
let mut buf = vec![FRAME_AUDIO];
buf.extend_from_slice(&4u32.to_le_bytes()); buf.extend_from_slice(&[1, 2]); let err = read_frame(&mut &buf[..]).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
}
#[test]
fn audio_commands_disambiguate_as_to_worker_cmd() {
for cmd in [
Command::StartRxAudio,
Command::StartTxAudio { rate: 8000 },
Command::SetRecording { on: true },
] {
let line = serde_json::to_string(&ToWorker::Cmd(cmd)).unwrap();
assert!(
matches!(
serde_json::from_str::<ToWorker>(&line).unwrap(),
ToWorker::Cmd(_)
),
"expected Cmd for {line}"
);
}
}
#[test]
fn set_recording_roundtrips() {
let line =
serde_json::to_string(&ToWorker::Cmd(Command::SetRecording { on: true })).unwrap();
assert_eq!(line, r#"{"cmd":"set_recording","on":true}"#);
match serde_json::from_str::<ToWorker>(&line).unwrap() {
ToWorker::Cmd(Command::SetRecording { on }) => assert!(on),
_ => panic!("expected set_recording"),
}
}
#[test]
fn saved_audio_reply_roundtrips() {
let file = WireSavedAudio {
path: "rec-recv.wav".into(),
kind: "recv".into(),
samples: 8000,
srate: 8000,
truncated: false,
};
let rep = serde_json::to_string(&FromWorker::Reply(Reply {
reply: 7,
result: ReplyResult::SavedAudio(vec![file]),
}))
.unwrap();
match serde_json::from_str::<FromWorker>(&rep).unwrap() {
FromWorker::Reply(Reply {
reply,
result: ReplyResult::SavedAudio(files),
}) => {
assert_eq!(reply, 7);
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "rec-recv.wav");
assert_eq!(files[0].kind, "recv");
assert_eq!(files[0].samples, 8000);
assert_eq!(files[0].srate, 8000);
assert!(!files[0].truncated);
}
_ => panic!("expected saved_audio reply"),
}
}
}