commit 0914f8b191ec90a9eb144868384c6bd6472d25cf Author: Laurent Pelecq Date: Sat May 15 19:24:18 2021 +0200 initial implementation to connect and quit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d217f29 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ssip-client" +version = "0.1.0" +authors = ["Laurent Pelecq "] +edition = "2018" +description = "Client API for Speech Dispatcher" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +dirs = "3" +thiserror = "1" + diff --git a/examples/list.rs b/examples/list.rs new file mode 100644 index 0000000..6a506e2 --- /dev/null +++ b/examples/list.rs @@ -0,0 +1,8 @@ +use ssip_client::ClientResult; + +fn main() -> ClientResult<()> { + let mut client = ssip_client::new_unix("joe", "list", "main")?; + let status = client.quit()?; + println!("status: {}", status); + Ok(()) +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..00e185a --- /dev/null +++ b/src/client.rs @@ -0,0 +1,76 @@ +use std::fmt; +use std::io::{self, Read, Write}; +use thiserror::Error as ThisError; + +pub type ReturnCode = u16; + +/// Command status line +/// +/// Consists in a 3-digits code and a message. It can be a success or a failure. +/// +/// Examples: +/// - 216 OK OUTPUT MODULE SET +/// - 409 ERR RATE TOO HIGH +#[derive(Debug, PartialEq)] +pub struct StatusLine { + pub code: ReturnCode, + pub message: String, +} + +impl fmt::Display for StatusLine { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{} {}", self.code, self.message) + } +} + +/// Client error, either I/O error or SSIP error. +#[derive(ThisError, Debug)] +pub enum ClientError { + #[error("I/O: {0}")] + Io(io::Error), + #[error("SSIP: {0}")] + Ssip(StatusLine), +} + +impl From for ClientError { + fn from(err: io::Error) -> Self { + ClientError::Io(err) + } +} + +/// Client result. +pub type ClientResult = Result; + +/// Client result consisting in a single status line +pub type ClientStatus = ClientResult; + +pub struct Client { + input: io::BufReader, + output: io::BufWriter, +} + +impl Client { + pub(crate) fn new( + mut input: io::BufReader, + mut output: io::BufWriter, + user: &str, + application: &str, + component: &str, + ) -> ClientResult { + // https://stackoverflow.com/questions/58467659/how-to-store-tcpstream-with-bufreader-and-bufwriter-in-a-data-structure + execute_command!( + &mut input, + &mut output, + "SET self CLIENT_NAME {}:{}:{}", + user, + application, + component + )?; + Ok(Self { input, output }) + } + + /// Close the connection + pub fn quit(&mut self) -> ClientStatus { + execute_command!(&mut self.input, &mut self.output, "QUIT") + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..d2df9cc --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,8 @@ +#[macro_use] +mod protocol; + +mod client; +mod unix; + +pub use client::{ClientResult, ClientStatus}; +pub use unix::new as new_unix; diff --git a/src/protocol.rs b/src/protocol.rs new file mode 100644 index 0000000..7faf14d --- /dev/null +++ b/src/protocol.rs @@ -0,0 +1,144 @@ +use std::io::{self, BufRead, Write}; + +use crate::client::{ClientError, ClientResult, ClientStatus, StatusLine}; + +macro_rules! invalid_input { + ($msg:expr) => { + ClientError::from(io::Error::new(io::ErrorKind::InvalidInput, $msg)) + }; + ($fmt:expr, $($arg:tt)*) => { + invalid_input!(format!($fmt, $($arg)*).as_str()) + }; +} + +macro_rules! send_command { + ($output:expr, $cmd:expr) => { + crate::protocol::send_command($output, $cmd) + }; + ($output:expr, $fmt:expr, $($arg:tt)*) => { + crate::protocol::send_command($output, format!($fmt, $($arg)*).as_str()) + }; +} + +macro_rules! execute_command { + ($input:expr, $output:expr, $cmd:expr) => { + send_command!($output, $cmd) + .and_then(|()| + crate::protocol::receive_answer($input, None)) + }; + ($input:expr, $output:expr, $fmt:expr, $($arg:tt)*) => { + send_command!($output, $fmt, $($arg)*) + .and_then(|()| + crate::protocol::receive_answer($input, None)) + }; +} + +pub(crate) fn send_command(output: &mut dyn Write, command: &str) -> ClientResult<()> { + output.write_all(command.as_bytes())?; + output.write_all(b"\r\n")?; + output.flush()?; + Ok(()) +} + +/// Parse the status line "OK msg" or "ERR msg" +fn parse_status_line(code: u16, line: &str) -> ClientStatus { + const TOKEN_OK: &str = "OK "; + const OFFSET_OK: usize = TOKEN_OK.len(); + const TOKEN_ERR: &str = "ERR "; + const OFFSET_ERR: usize = TOKEN_ERR.len(); + if line.starts_with(TOKEN_OK) { + let message = line[OFFSET_OK..].to_string(); + Ok(StatusLine { code, message }) + } else if line.starts_with(TOKEN_ERR) { + let message = line[OFFSET_ERR..].to_string(); + Err(ClientError::Ssip(StatusLine { code, message })) + } else { + let status = StatusLine { + code, + message: line.to_string(), + }; + if (300..700).contains(&code) { + Err(ClientError::Ssip(status)) + } else { + Ok(status) + } + } +} + +pub(crate) fn receive_answer( + input: &mut dyn BufRead, + mut lines: Option<&mut Vec>, +) -> ClientStatus { + loop { + let mut line = String::new(); + input.read_line(&mut line).map_err(ClientError::Io)?; + match line.chars().nth(3) { + Some(ch) => match ch { + ' ' => match line[0..3].parse::() { + Ok(code) => return parse_status_line(code, &line[4..].trim_end()), + Err(err) => return Err(invalid_input!(err.to_string())), + }, + '-' => match lines { + Some(ref mut lines) => lines.push(line[4..].trim_end().to_string()), + None => return Err(invalid_input!("unexpected line: {}", line)), + }, + ch => { + return Err(invalid_input!("expecting space or dash, got {}.", ch)); + } + }, + None => return Err(invalid_input!("line too short")), + } + } +} + +#[cfg(test)] +mod tests { + + use std::io::BufReader; + + use super::{receive_answer, ClientError}; + + #[test] + fn single_ok_status_line() { + let mut input = BufReader::new("208 OK CLIENT NAME SET\r\n".as_bytes()); + let status = receive_answer(&mut input, None).unwrap(); + assert_eq!(208, status.code); + assert_eq!("CLIENT NAME SET", status.message); + } + + #[test] + fn single_success_status_line() { + let mut input = BufReader::new("231 HAPPY HACKING\r\n".as_bytes()); + let status = receive_answer(&mut input, None).unwrap(); + assert_eq!(231, status.code); + assert_eq!("HAPPY HACKING", status.message); + } + + #[test] + fn single_err_status_line() { + let mut input = BufReader::new("409 ERR RATE TOO HIGH\r\n".as_bytes()); + match receive_answer(&mut input, None).err().unwrap() { + ClientError::Ssip(status) => { + assert_eq!(409, status.code); + assert_eq!("RATE TOO HIGH", status.message); + } + err => panic!("{}: invalid error", err), + } + } + + #[test] + fn multi_lines() { + let mut input = BufReader::new( + "249-afrikaans\taf\tnone\r\n249-en-rhotic\ten\tr\r\n249 OK VOICE LIST SENT\r\n" + .as_bytes(), + ); + let mut lines = Vec::new(); + let status = receive_answer(&mut input, Some(&mut lines)).unwrap(); + assert_eq!(249, status.code); + assert_eq!("VOICE LIST SENT", status.message); + assert_eq!( + vec!["afrikaans\taf\tnone", "en-rhotic\ten\tr"], + lines.as_slice() + ); + } +} diff --git a/src/unix.rs b/src/unix.rs new file mode 100644 index 0000000..2ec4a0e --- /dev/null +++ b/src/unix.rs @@ -0,0 +1,29 @@ +use std::io; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; + +use crate::client::{Client, ClientResult}; + +const SPEECHD_APPLICATION_NAME: &str = "speech-dispatcher"; +const SPEECHD_SOCKET_NAME: &str = "speechd.sock"; + +/// Return the standard socket according to the [freedesktop.org](https://www.freedesktop.org/) specification. +fn speech_dispatcher_socket() -> io::Result { + match dirs::runtime_dir() { + Some(runtime_dir) => Ok(runtime_dir + .join(SPEECHD_APPLICATION_NAME) + .join(SPEECHD_SOCKET_NAME)), + None => Err(io::Error::new( + io::ErrorKind::NotFound, + "unix socket not found", + )), + } +} + +pub fn new(user: &str, application: &str, component: &str) -> ClientResult> { + let socket_path = speech_dispatcher_socket()?; + let stream = UnixStream::connect(socket_path)?; + let input = io::BufReader::new(stream.try_clone()?); + let output = io::BufWriter::new(stream); + Client::new(input, output, user, application, component) +}