diff --git a/src/args.rs b/src/args.rs index 78f91cc..e2bbde3 100644 --- a/src/args.rs +++ b/src/args.rs @@ -76,6 +76,15 @@ pub enum GetSmsCommands { pub enum PostCommands { /// Reboot the router. Reboot, + /// Start an interactive USSD session. + Ussd { + /// USSD code (e.g. '*704#') or a built-in name. + /// + /// Built-in names: menu (*777#), balance (*704#), + /// bundles (*777*02#), mpesa (*733#). + #[arg(value_parser = parse_ussd_code)] + code: BoxStr, + }, /// SMS actions. #[command(flatten_help = true)] Sms { @@ -107,6 +116,36 @@ pub enum PostSmsCommands { }, } +/// Built-in Safaricom Ethiopia USSD codes, dialable by name. "balance" +/// and "bundles" are what the router dashboard's own buttons dial. +const USSD_CODES: &[(&str, &str)] = &[ + ("menu", "*777#"), + ("balance", "*704#"), + ("bundles", "*777*02#"), + ("mpesa", "*733#"), +]; + +fn parse_ussd_code(raw: &str) -> Result { + if raw.contains('*') || raw.contains('#') { + return Ok(raw.into()); + } + + USSD_CODES + .iter() + .find(|(name, _)| raw.eq_ignore_ascii_case(name)) + .map(|(_, code)| (*code).into()) + .ok_or_else(|| { + let names: BoxList = USSD_CODES + .iter() + .map(|(name, code)| format!("{name} ({code})")) + .collect(); + format!( + "expected a USSD code or a built-in name, got {raw:?}; built-ins: {}", + names.join(", ") + ) + }) +} + /// A message ID argument that also accepts the literal "all". #[derive(Debug, Clone, Copy)] pub enum MsgSelector { diff --git a/src/common.rs b/src/common.rs index 02a2757..23197fc 100644 --- a/src/common.rs +++ b/src/common.rs @@ -97,6 +97,25 @@ pub fn page_or_print(text: &str) -> EyreResult<()> { Ok(()) } +/// Print a block of text bracketed by horizontal rules sized to its +/// widest line, so successive blocks (e.g. USSD menu steps) stay visually +/// separate. The rule width is clamped to keep short replies from drawing +/// a tiny line and long ones from spanning the whole terminal. +pub fn print_framed(text: &str) { + const MIN_RULE: usize = 24; + const MAX_RULE: usize = 60; + + let width = text + .lines() + .map(|line| line.chars().count()) + .max() + .unwrap_or(0) + .clamp(MIN_RULE, MAX_RULE); + + let rule = "─".repeat(width); + println!("{rule}\n{text}\n{rule}"); +} + /// Substitute a dash for fields the router reports as empty. pub const fn or_dash(value: &str) -> &str { if value.is_empty() { "—" } else { value } diff --git a/src/main.rs b/src/main.rs index ada6700..a6039e0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,7 @@ async fn main() -> EyreResult<()> { // The router goes down mid-reboot, so don't try to log out. return router.reboot().await; } + PostCommands::Ussd { code } => router.ussd_session(&code).await?, PostCommands::Sms { command } => match command { PostSmsCommands::Send { number, message } => { let params = SendSmsParams::new(&number, &message); diff --git a/src/proc_get.rs b/src/proc_get.rs index cdd9eb0..d69a745 100644 --- a/src/proc_get.rs +++ b/src/proc_get.rs @@ -178,6 +178,58 @@ impl Show for AirtimeBalance { } } +/// State of an in-flight USSD transaction (`ussd_write_flag`). The codes +/// and their meanings are taken from the router's own web UI, which polls +/// on "15", reads the response on exactly "16", and treats every other +/// value as a specific error. +#[derive(Debug, Clone)] +pub enum UssdFlag { + /// The network hasn't answered yet ("15"). + Pending, + /// A response is ready to read ("16"). + Ready, + /// The transaction failed; carries a human-readable reason. + Failed(BoxStr), +} + +impl<'de> serde::Deserialize<'de> for UssdFlag { + fn deserialize>(deserializer: D) -> Result { + let raw = BoxStr::deserialize(deserializer)?; + Ok(match raw.as_ref() { + "15" => Self::Pending, + "16" => Self::Ready, + "1" => Self::Failed("no network service".into()), + "2" => Self::Failed("network terminated the session".into()), + "3" | "4" | "unknown" => Self::Failed("timed out".into()), + "10" => Self::Failed("please retry".into()), + "41" => Self::Failed("operation not supported".into()), + "99" => Self::Failed("USSD not supported".into()), + other => Self::Failed(format!("unexpected status code {other}").into()), + }) + } +} + +#[derive(Debug, Deserialize)] +pub struct UssdWriteFlag { + pub ussd_write_flag: UssdFlag, +} + +impl ProcGet for UssdWriteFlag { + const CMD: &str = "ussd_write_flag"; + type Params = (); +} + +#[derive(Debug, Deserialize)] +pub struct UssdData { + #[serde(rename = "ussd_data", deserialize_with = "de::ucs2")] + pub text: BoxStr, +} + +impl ProcGet for UssdData { + const CMD: &str = "ussd_data_info"; + type Params = (); +} + /// Signal metrics live in the `system_status` read; the standalone `rssi` /// cmd echoes RSRP instead of the real RSSI on this firmware. The cell /// identifiers are kept as text since they're empty outside LTE. diff --git a/src/proc_post.rs b/src/proc_post.rs index cc065b5..a0bd427 100644 --- a/src/proc_post.rs +++ b/src/proc_post.rs @@ -149,6 +149,54 @@ impl Show for SendSms { } } +#[derive(Debug, Deserialize)] +pub struct UssdProcess { + pub result: BoxStr, +} + +#[derive(Debug, Serialize, Default)] +#[serde(tag = "USSD_operator")] +pub enum UssdParams { + #[serde(rename = "ussd_send")] + Send { + #[serde(rename = "USSD_send_number")] + number: BoxStr, + #[serde(rename = "notCallback")] + not_callback: BoxStr, + }, + #[serde(rename = "ussd_reply")] + Reply { + #[serde(rename = "USSD_reply_number")] + number: BoxStr, + #[serde(rename = "notCallback")] + not_callback: BoxStr, + }, + #[default] + #[serde(rename = "ussd_cancel")] + Cancel, +} + +impl UssdParams { + pub fn send(code: &str) -> Self { + Self::Send { + number: code.into(), + not_callback: "true".into(), + } + } + + pub fn reply(input: &str) -> Self { + Self::Reply { + number: input.into(), + not_callback: "true".into(), + } + } +} + +impl ProcPost for UssdProcess { + const GOFORM_ID: &str = "USSD_PROCESS"; + type Params = UssdParams; +} + #[derive(Debug, Deserialize)] pub struct MarkSms { pub result: BoxStr, diff --git a/src/retry.rs b/src/retry.rs index aad804b..4f645ae 100644 --- a/src/retry.rs +++ b/src/retry.rs @@ -17,7 +17,7 @@ where Err(e) if attempt == MAX_ATTEMPTS => return Err(e), Err(_) => { let duration = BASE_DELAY * 2u32.pow(attempt - 1); - println!("Retrying in {}ms", duration.as_millis()); + eprintln!("Retrying in {}ms", duration.as_millis()); tokio::time::sleep(duration).await; } } diff --git a/src/router.rs b/src/router.rs index f298268..06ab882 100644 --- a/src/router.rs +++ b/src/router.rs @@ -155,6 +155,106 @@ impl Router { self.post_with::(params).await?.show() } + /// Run an interactive USSD session: dial the code, print each network + /// response, and read menu replies from stdin until EOF / "q" / an + /// empty line. The network session is always cancelled on the way out. + pub async fn ussd_session(&self, code: &str) -> EyreResult<()> { + self.post_with::(UssdParams::send(code)) + .await?; + + let dialog = self.ussd_dialog().await; + let _ = self.post_with::(UssdParams::Cancel).await; + + dialog + } + + async fn ussd_dialog(&self) -> EyreResult<()> { + use std::io::IsTerminal; + + self.ussd_print_response().await?; + + if std::io::stdin().is_terminal() { + self.ussd_dialog_interactive().await + } else { + self.ussd_dialog_piped().await + } + } + + async fn ussd_dialog_interactive(&self) -> EyreResult<()> { + use rustyline::error::ReadlineError; + + let mut editor = rustyline::DefaultEditor::new()?; + loop { + let line = match editor.readline("> ") { + Ok(line) => line, + // Ctrl-C / Ctrl-D quit the dialog, not the process, so + // the session still gets cancelled on the way out. + Err(ReadlineError::Interrupted | ReadlineError::Eof) => break, + Err(e) => return Err(e.into()), + }; + + let reply = line.trim(); + if reply.is_empty() || reply == "q" { + break; + } + + let _ = editor.add_history_entry(reply); + self.ussd_reply(reply).await?; + } + + Ok(()) + } + + /// Piped stdin (`echo 2 | kimem post ussd menu`): no prompts, no + /// line editing, just replies. + async fn ussd_dialog_piped(&self) -> EyreResult<()> { + use std::io::BufRead; + + for line in std::io::stdin().lock().lines() { + let line = line?; + let reply = line.trim(); + if reply.is_empty() || reply == "q" { + break; + } + + self.ussd_reply(reply).await?; + } + + Ok(()) + } + + async fn ussd_reply(&self, reply: &str) -> EyreResult<()> { + self.post_with::(UssdParams::reply(reply)) + .await?; + self.ussd_print_response().await + } + + /// Poll until the network answers, then print the decoded response. + async fn ussd_print_response(&self) -> EyreResult<()> { + const MAX_POLLS: u32 = 30; + + let mut polls = 0; + loop { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + match self.get::().await?.ussd_write_flag { + UssdFlag::Ready => break, + UssdFlag::Failed(reason) => bail!("USSD request failed: {reason}"), + UssdFlag::Pending => { + polls += 1; + if polls == MAX_POLLS { + bail!("USSD request timed out after {MAX_POLLS}s"); + } + } + } + } + + let response = self.get::().await?; + print_framed(response.text.trim()); + + Ok(()) + } + /// Signal metrics come from `system_status`, but TAC and EARFCN only /// exist as standalone cmds; join the two reads into one report. pub async fn show_signal(&self) -> EyreResult<()> {