1use ringo_core::account::{Account, BackendOptions};
16use ringo_core::event::{AppEvent, InviteHeaders, MediaStats};
17use serde::{Deserialize, Serialize};
18use std::io::{self, Read, Write};
19
20pub(crate) const FRAME_CONTROL: u8 = 0;
29pub(crate) const FRAME_AUDIO: u8 = 1;
31
32const MAX_FRAME_LEN: usize = 16 * 1024 * 1024;
34
35pub(crate) fn write_frame<W: Write>(w: &mut W, kind: u8, payload: &[u8]) -> io::Result<()> {
37 if payload.len() > MAX_FRAME_LEN {
38 return Err(io::Error::new(
39 io::ErrorKind::InvalidInput,
40 "frame too large",
41 ));
42 }
43 w.write_all(&[kind])?;
44 w.write_all(&(payload.len() as u32).to_le_bytes())?;
45 w.write_all(payload)?;
46 w.flush()
47}
48
49pub(crate) fn read_frame<R: Read>(r: &mut R) -> io::Result<Option<(u8, Vec<u8>)>> {
53 let mut hdr = [0u8; 5];
54 let mut filled = 0;
57 while filled < hdr.len() {
58 match r.read(&mut hdr[filled..]) {
59 Ok(0) if filled == 0 => return Ok(None), Ok(0) => {
61 return Err(io::Error::new(
62 io::ErrorKind::UnexpectedEof,
63 "EOF in frame header",
64 ));
65 }
66 Ok(n) => filled += n,
67 Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
68 Err(e) => return Err(e),
69 }
70 }
71 let kind = hdr[0];
72 let len = u32::from_le_bytes([hdr[1], hdr[2], hdr[3], hdr[4]]) as usize;
73 if len > MAX_FRAME_LEN {
74 return Err(io::Error::new(
75 io::ErrorKind::InvalidData,
76 "frame too large",
77 ));
78 }
79 let mut payload = vec![0u8; len];
80 r.read_exact(&mut payload)?;
81 Ok(Some((kind, payload)))
82}
83
84pub(crate) fn pcm_to_bytes(samples: &[i16]) -> Vec<u8> {
86 let mut v = Vec::with_capacity(samples.len() * 2);
87 for &s in samples {
88 v.extend_from_slice(&s.to_le_bytes());
89 }
90 v
91}
92
93pub(crate) fn bytes_to_pcm(bytes: &[u8]) -> Vec<i16> {
95 bytes
96 .chunks_exact(2)
97 .map(|c| i16::from_le_bytes([c[0], c[1]]))
98 .collect()
99}
100
101pub(crate) const PROTO_VERSION: u32 = 2;
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
113pub(crate) struct Handshake {
114 pub proto_version: u32,
116 pub config: AgentConfig,
118}
119
120#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127pub struct AgentConfig {
128 pub name: String,
130 pub account: Account,
132 pub options: BackendOptions,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(tag = "cmd", rename_all = "snake_case")]
141pub enum Command {
142 Register {
143 aor: String,
144 regint: u32,
145 },
146 Dial {
147 number: String,
148 },
149 Hangup,
150 HangupAll,
151 Accept,
152 Hold,
153 Resume,
154 Mute,
155 Dtmf {
156 digit: char,
157 },
158 SwitchLine {
159 line: usize,
160 },
161 Transfer {
162 uri: String,
163 },
164 AttendedTransferStart {
165 uri: String,
166 },
167 AttendedTransferExec,
168 AttendedTransferAbort,
169 AddHeader {
170 key: String,
171 value: String,
172 },
173 RmHeader {
174 key: String,
175 },
176 SetAudioSource {
177 spec: String,
178 },
179 SetRecording {
182 on: bool,
183 },
184 ArmInviteResponse {
185 scode: u16,
186 reason: String,
187 headers: Vec<String>,
188 },
189 DisarmInviteResponse,
190 StartTxAudio {
193 rate: u32,
194 },
195 StartRxAudio,
198 Shutdown,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
204#[serde(tag = "query", rename_all = "snake_case")]
205pub enum Query {
206 MediaStats,
208 ReceivedDtmf,
210 SaveAudio { prefix: String },
214 CallCount,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct QueryEnvelope {
221 pub id: u64,
222 #[serde(flatten)]
223 pub query: Query,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
230#[serde(untagged)]
231pub enum ToWorker {
232 Cmd(Command),
233 Query(QueryEnvelope),
234}
235
236#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
240pub struct WireMediaStats {
241 pub rtt_ms: f64,
242 pub jitter_ms: f64,
243 pub rx_lost: i32,
244 pub packet_loss_pct: f64,
245 pub mos: f64,
246}
247
248impl From<MediaStats> for WireMediaStats {
249 fn from(s: MediaStats) -> Self {
250 Self {
251 rtt_ms: s.rtt_ms,
252 jitter_ms: s.jitter_ms,
253 rx_lost: s.rx_lost,
254 packet_loss_pct: s.packet_loss_pct,
255 mos: s.mos,
256 }
257 }
258}
259
260impl From<WireMediaStats> for MediaStats {
261 fn from(s: WireMediaStats) -> Self {
262 Self {
263 rtt_ms: s.rtt_ms,
264 jitter_ms: s.jitter_ms,
265 rx_lost: s.rx_lost,
266 packet_loss_pct: s.packet_loss_pct,
267 mos: s.mos,
268 }
269 }
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct WireSavedAudio {
277 pub path: String,
278 pub kind: String,
280 pub samples: usize,
282 pub srate: u32,
284 pub truncated: bool,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
292#[serde(rename_all = "snake_case")]
293pub enum ReplyResult {
294 MediaStats(Option<WireMediaStats>),
295 Dtmf(String),
296 SavedAudio(Vec<WireSavedAudio>),
297 CallCount(u32),
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct Reply {
304 pub reply: u64,
305 pub result: ReplyResult,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct Headers {
311 pub headers: InviteHeaders,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct Ready {
319 pub ready: bool,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct RxAudioStarted {
326 pub rx_audio_rate: u32,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
334#[serde(untagged)]
335pub enum FromWorker {
336 Ready(Ready),
337 Event(WireEvent),
338 Reply(Reply),
339 Headers(Headers),
340 RxAudioStarted(RxAudioStarted),
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize)]
346#[serde(tag = "event", rename_all = "snake_case")]
347pub enum WireEvent {
348 Registering {
349 account: String,
350 },
351 RegisterOk {
352 account: String,
353 },
354 RegisterFailed {
355 reason: String,
356 },
357 Unregistered {
358 account: String,
359 },
360 CallIncoming {
361 call_id: String,
362 number: String,
363 display_name: Option<String>,
364 },
365 CallOutgoing {
366 call_id: String,
367 number: String,
368 },
369 CallRinging {
370 call_id: String,
371 },
372 CallEstablished {
373 call_id: String,
374 },
375 CallClosed {
376 call_id: String,
377 reason: String,
378 error: bool,
379 },
380 CallDeflected {
381 from: String,
382 display_name: Option<String>,
383 target: String,
384 },
385 CallHold {
386 call_id: String,
387 },
388 CallResume {
389 call_id: String,
390 },
391 CallTransferFailed {
392 call_id: String,
393 },
394 VoicemailStatus {
395 waiting: bool,
396 new_count: u32,
397 },
398 Response {
399 ok: bool,
400 data: String,
401 },
402 Unknown {
403 class: String,
404 type_: String,
405 },
406 BackendConnectFailed {
407 reason: String,
408 },
409}
410
411impl From<&AppEvent> for WireEvent {
412 fn from(e: &AppEvent) -> Self {
413 match e {
414 AppEvent::Registering { account } => Self::Registering {
415 account: account.clone(),
416 },
417 AppEvent::RegisterOk { account } => Self::RegisterOk {
418 account: account.clone(),
419 },
420 AppEvent::RegisterFailed { reason } => Self::RegisterFailed {
421 reason: reason.clone(),
422 },
423 AppEvent::Unregistered { account } => Self::Unregistered {
424 account: account.clone(),
425 },
426 AppEvent::CallIncoming {
427 call_id,
428 number,
429 display_name,
430 } => Self::CallIncoming {
431 call_id: call_id.clone(),
432 number: number.clone(),
433 display_name: display_name.clone(),
434 },
435 AppEvent::CallOutgoing { call_id, number } => Self::CallOutgoing {
436 call_id: call_id.clone(),
437 number: number.clone(),
438 },
439 AppEvent::CallRinging { call_id } => Self::CallRinging {
440 call_id: call_id.clone(),
441 },
442 AppEvent::CallEstablished { call_id } => Self::CallEstablished {
443 call_id: call_id.clone(),
444 },
445 AppEvent::CallClosed {
446 call_id,
447 reason,
448 error,
449 } => Self::CallClosed {
450 call_id: call_id.clone(),
451 reason: reason.clone(),
452 error: *error,
453 },
454 AppEvent::CallHold { call_id } => Self::CallHold {
455 call_id: call_id.clone(),
456 },
457 AppEvent::CallResume { call_id } => Self::CallResume {
458 call_id: call_id.clone(),
459 },
460 AppEvent::CallTransferFailed { call_id } => Self::CallTransferFailed {
461 call_id: call_id.clone(),
462 },
463 AppEvent::CallDeflected {
464 from,
465 display_name,
466 target,
467 } => Self::CallDeflected {
468 from: from.clone(),
469 display_name: display_name.clone(),
470 target: target.clone(),
471 },
472 AppEvent::VoicemailStatus { waiting, new_count } => Self::VoicemailStatus {
473 waiting: *waiting,
474 new_count: *new_count,
475 },
476 AppEvent::Response { ok, data } => Self::Response {
477 ok: *ok,
478 data: data.clone(),
479 },
480 AppEvent::Unknown { class, type_ } => Self::Unknown {
481 class: class.clone(),
482 type_: type_.clone(),
483 },
484 AppEvent::BackendConnectFailed { reason } => Self::BackendConnectFailed {
485 reason: reason.clone(),
486 },
487 }
488 }
489}
490
491impl From<WireEvent> for AppEvent {
492 fn from(w: WireEvent) -> Self {
493 match w {
494 WireEvent::Registering { account } => AppEvent::Registering { account },
495 WireEvent::RegisterOk { account } => AppEvent::RegisterOk { account },
496 WireEvent::RegisterFailed { reason } => AppEvent::RegisterFailed { reason },
497 WireEvent::Unregistered { account } => AppEvent::Unregistered { account },
498 WireEvent::CallIncoming {
499 call_id,
500 number,
501 display_name,
502 } => AppEvent::CallIncoming {
503 call_id,
504 number,
505 display_name,
506 },
507 WireEvent::CallOutgoing { call_id, number } => {
508 AppEvent::CallOutgoing { call_id, number }
509 }
510 WireEvent::CallRinging { call_id } => AppEvent::CallRinging { call_id },
511 WireEvent::CallEstablished { call_id } => AppEvent::CallEstablished { call_id },
512 WireEvent::CallClosed {
513 call_id,
514 reason,
515 error,
516 } => AppEvent::CallClosed {
517 call_id,
518 reason,
519 error,
520 },
521 WireEvent::CallHold { call_id } => AppEvent::CallHold { call_id },
522 WireEvent::CallResume { call_id } => AppEvent::CallResume { call_id },
523 WireEvent::CallTransferFailed { call_id } => AppEvent::CallTransferFailed { call_id },
524 WireEvent::CallDeflected {
525 from,
526 display_name,
527 target,
528 } => AppEvent::CallDeflected {
529 from,
530 display_name,
531 target,
532 },
533 WireEvent::VoicemailStatus { waiting, new_count } => {
534 AppEvent::VoicemailStatus { waiting, new_count }
535 }
536 WireEvent::Response { ok, data } => AppEvent::Response { ok, data },
537 WireEvent::Unknown { class, type_ } => AppEvent::Unknown { class, type_ },
538 WireEvent::BackendConnectFailed { reason } => AppEvent::BackendConnectFailed { reason },
539 }
540 }
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546
547 #[test]
548 fn command_roundtrips_as_to_worker() {
549 let line = serde_json::to_string(&ToWorker::Cmd(Command::Dial {
550 number: "sip:1@example.com".into(),
551 }))
552 .unwrap();
553 assert_eq!(line, r#"{"cmd":"dial","number":"sip:1@example.com"}"#);
554 assert!(matches!(
555 serde_json::from_str::<ToWorker>(&line).unwrap(),
556 ToWorker::Cmd(Command::Dial { .. })
557 ));
558 }
559
560 #[test]
561 fn query_envelope_roundtrips_as_to_worker() {
562 let line = serde_json::to_string(&ToWorker::Query(QueryEnvelope {
563 id: 7,
564 query: Query::SaveAudio {
565 prefix: "rec".into(),
566 },
567 }))
568 .unwrap();
569 let back: ToWorker = serde_json::from_str(&line).unwrap();
570 match back {
571 ToWorker::Query(QueryEnvelope {
572 id,
573 query: Query::SaveAudio { prefix },
574 }) => {
575 assert_eq!((id, prefix.as_str()), (7, "rec"));
576 }
577 _ => panic!("expected save_audio query"),
578 }
579 }
580
581 #[test]
582 fn event_and_reply_disambiguate_as_from_worker() {
583 let ev = serde_json::to_string(&FromWorker::Event(WireEvent::RegisterOk {
584 account: "a".into(),
585 }))
586 .unwrap();
587 assert!(matches!(
588 serde_json::from_str::<FromWorker>(&ev).unwrap(),
589 FromWorker::Event(WireEvent::RegisterOk { .. })
590 ));
591
592 let rep = serde_json::to_string(&FromWorker::Reply(Reply {
593 reply: 7,
594 result: ReplyResult::CallCount(2),
595 }))
596 .unwrap();
597 match serde_json::from_str::<FromWorker>(&rep).unwrap() {
598 FromWorker::Reply(Reply {
599 reply,
600 result: ReplyResult::CallCount(n),
601 }) => {
602 assert_eq!((reply, n), (7, 2));
603 }
604 _ => panic!("expected call_count reply"),
605 }
606 }
607
608 #[test]
609 fn app_event_mirrors_both_ways() {
610 let ev = AppEvent::CallIncoming {
611 call_id: "abc".into(),
612 number: "sip:1@x".into(),
613 display_name: Some("Bob".into()),
614 };
615 let line = serde_json::to_string(&WireEvent::from(&ev)).unwrap();
616 let back: AppEvent = serde_json::from_str::<WireEvent>(&line).unwrap().into();
617 assert!(matches!(back, AppEvent::CallIncoming { .. }));
618 }
619
620 #[test]
621 fn ready_disambiguates_from_an_event() {
622 let ready = serde_json::to_string(&FromWorker::Ready(Ready { ready: true })).unwrap();
623 assert!(matches!(
624 serde_json::from_str::<FromWorker>(&ready).unwrap(),
625 FromWorker::Ready(Ready { ready: true })
626 ));
627 let ev = serde_json::to_string(&FromWorker::Event(WireEvent::RegisterOk {
629 account: "a".into(),
630 }))
631 .unwrap();
632 assert!(matches!(
633 serde_json::from_str::<FromWorker>(&ev).unwrap(),
634 FromWorker::Event(_)
635 ));
636 }
637
638 #[test]
639 fn handshake_carries_version_and_config() {
640 let mut cfg = AgentConfig {
641 name: "A".into(),
642 ..Default::default()
643 };
644 cfg.account.username = "alice".into();
645 let hs = Handshake {
646 proto_version: PROTO_VERSION,
647 config: cfg,
648 };
649 let line = serde_json::to_string(&hs).unwrap();
650 let back: Handshake = serde_json::from_str(&line).unwrap();
651 assert_eq!(back.proto_version, PROTO_VERSION);
652 assert_eq!(back.config.account.username, "alice");
653 }
654
655 #[test]
656 fn agent_config_roundtrips_with_embedded_account() {
657 let mut cfg = AgentConfig {
658 name: "A".into(),
659 ..Default::default()
660 };
661 cfg.account.username = "alice".into();
662 cfg.account.catchall = true;
663 cfg.options.audio_driver = Some("aubridge".into());
664 let line = serde_json::to_string(&cfg).unwrap();
665 let back: AgentConfig = serde_json::from_str(&line).unwrap();
666 assert_eq!(back.name, "A");
667 assert_eq!(back.account.username, "alice");
668 assert!(back.account.catchall);
669 assert_eq!(back.options.audio_driver.as_deref(), Some("aubridge"));
670 }
671
672 #[test]
673 fn frames_roundtrip_control_then_audio() {
674 let mut buf: Vec<u8> = Vec::new();
675 write_frame(&mut buf, FRAME_CONTROL, b"{\"cmd\":\"accept\"}").unwrap();
676 write_frame(&mut buf, FRAME_AUDIO, &pcm_to_bytes(&[1, -2, 3])).unwrap();
677 let mut r = &buf[..];
678 let (k1, p1) = read_frame(&mut r).unwrap().unwrap();
679 assert_eq!(k1, FRAME_CONTROL);
680 assert_eq!(p1, b"{\"cmd\":\"accept\"}");
681 let (k2, p2) = read_frame(&mut r).unwrap().unwrap();
682 assert_eq!(k2, FRAME_AUDIO);
683 assert_eq!(bytes_to_pcm(&p2), vec![1, -2, 3]);
684 assert!(read_frame(&mut r).unwrap().is_none()); }
686
687 #[test]
688 fn audio_frame_can_contain_newline_bytes() {
689 let samples = vec![0x0A0Ai16, 0x000A, -1];
691 let mut buf = Vec::new();
692 write_frame(&mut buf, FRAME_AUDIO, &pcm_to_bytes(&samples)).unwrap();
693 let (_k, p) = read_frame(&mut &buf[..]).unwrap().unwrap();
694 assert_eq!(bytes_to_pcm(&p), samples);
695 }
696
697 #[test]
698 fn rx_audio_started_disambiguates_from_event_and_reply() {
699 let started = serde_json::to_string(&FromWorker::RxAudioStarted(RxAudioStarted {
700 rx_audio_rate: 8000,
701 }))
702 .unwrap();
703 assert!(matches!(
704 serde_json::from_str::<FromWorker>(&started).unwrap(),
705 FromWorker::RxAudioStarted(RxAudioStarted {
706 rx_audio_rate: 8000
707 })
708 ));
709 let ev = serde_json::to_string(&FromWorker::Event(WireEvent::RegisterOk {
711 account: "a".into(),
712 }))
713 .unwrap();
714 let rep = serde_json::to_string(&FromWorker::Reply(Reply {
715 reply: 1,
716 result: ReplyResult::CallCount(0),
717 }))
718 .unwrap();
719 assert!(matches!(
720 serde_json::from_str::<FromWorker>(&ev).unwrap(),
721 FromWorker::Event(_)
722 ));
723 assert!(matches!(
724 serde_json::from_str::<FromWorker>(&rep).unwrap(),
725 FromWorker::Reply(_)
726 ));
727 }
728
729 #[test]
730 fn read_frame_distinguishes_clean_eof_from_torn_header() {
731 assert!(read_frame(&mut &b""[..]).unwrap().is_none());
733 let err = read_frame(&mut &[FRAME_CONTROL, 0x01][..]).unwrap_err();
735 assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
736 }
737
738 #[test]
739 fn read_frame_rejects_oversized_length() {
740 let mut buf = vec![FRAME_AUDIO];
742 buf.extend_from_slice(&(MAX_FRAME_LEN as u32 + 1).to_le_bytes());
743 let err = read_frame(&mut &buf[..]).unwrap_err();
744 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
745 }
746
747 #[test]
748 fn read_frame_errors_on_truncated_payload() {
749 let mut buf = vec![FRAME_AUDIO];
751 buf.extend_from_slice(&4u32.to_le_bytes()); buf.extend_from_slice(&[1, 2]); let err = read_frame(&mut &buf[..]).unwrap_err();
754 assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
755 }
756
757 #[test]
758 fn audio_commands_disambiguate_as_to_worker_cmd() {
759 for cmd in [
761 Command::StartRxAudio,
762 Command::StartTxAudio { rate: 8000 },
763 Command::SetRecording { on: true },
764 ] {
765 let line = serde_json::to_string(&ToWorker::Cmd(cmd)).unwrap();
766 assert!(
767 matches!(
768 serde_json::from_str::<ToWorker>(&line).unwrap(),
769 ToWorker::Cmd(_)
770 ),
771 "expected Cmd for {line}"
772 );
773 }
774 }
775
776 #[test]
777 fn set_recording_roundtrips() {
778 let line =
779 serde_json::to_string(&ToWorker::Cmd(Command::SetRecording { on: true })).unwrap();
780 assert_eq!(line, r#"{"cmd":"set_recording","on":true}"#);
781 match serde_json::from_str::<ToWorker>(&line).unwrap() {
782 ToWorker::Cmd(Command::SetRecording { on }) => assert!(on),
783 _ => panic!("expected set_recording"),
784 }
785 }
786
787 #[test]
788 fn saved_audio_reply_roundtrips() {
789 let file = WireSavedAudio {
790 path: "rec-recv.wav".into(),
791 kind: "recv".into(),
792 samples: 8000,
793 srate: 8000,
794 truncated: false,
795 };
796 let rep = serde_json::to_string(&FromWorker::Reply(Reply {
797 reply: 7,
798 result: ReplyResult::SavedAudio(vec![file]),
799 }))
800 .unwrap();
801 match serde_json::from_str::<FromWorker>(&rep).unwrap() {
802 FromWorker::Reply(Reply {
803 reply,
804 result: ReplyResult::SavedAudio(files),
805 }) => {
806 assert_eq!(reply, 7);
807 assert_eq!(files.len(), 1);
808 assert_eq!(files[0].path, "rec-recv.wav");
809 assert_eq!(files[0].kind, "recv");
810 assert_eq!(files[0].samples, 8000);
811 assert_eq!(files[0].srate, 8000);
812 assert!(!files[0].truncated);
813 }
814 _ => panic!("expected saved_audio reply"),
815 }
816 }
817}