| 1 | //! The JSON between the app's Kotlin side and the worker (docs/android.md#boundary). |
| 2 | //! |
| 3 | //! Commands come in as `{"command": "noise", "mode": "anc", "level": "deep"}`, and events go out |
| 4 | //! as `{"event": "noise", "mode": "anc", "level": "deep"}`. The values are the status types the |
| 5 | //! CLI's `--json` writes, except that switches keep their labels. |
| 6 | |
| 7 | use budctl_core::status::{AncLevel, EqBand, NoiseMode, Status}; |
| 8 | use budctl_core::worker::{Command, Event}; |
| 9 | use budctl_core::{Error, Kind, PairedDevice, Result}; |
| 10 | use serde::Deserialize; |
| 11 | use serde_json::{Map, Value, to_value}; |
| 12 | |
| 13 | use crate::phone::app_error; |
| 14 | |
| 15 | /// A command, as the app sends it. |
| 16 | #[derive(Debug, Deserialize)] |
| 17 | #[serde(tag = "command", rename_all = "kebab-case")] |
| 18 | enum Request { |
| 19 | Read, |
| 20 | /// `off`, `transparency` or `anc`; for `anc`, `light`, `medium` or `deep` (the default). |
| 21 | Noise { |
| 22 | mode: String, |
| 23 | level: Option<String>, |
| 24 | }, |
| 25 | Switch { |
| 26 | key: String, |
| 27 | on: bool, |
| 28 | }, |
| 29 | Eq { |
| 30 | preset: String, |
| 31 | }, |
| 32 | CustomEq { |
| 33 | preset: String, |
| 34 | bands: Vec<Band>, |
| 35 | }, |
| 36 | Touch { |
| 37 | side: String, |
| 38 | gesture: String, |
| 39 | action: String, |
| 40 | }, |
| 41 | HoldCycle { |
| 42 | modes: Vec<String>, |
| 43 | }, |
| 44 | Find { |
| 45 | ring: bool, |
| 46 | }, |
| 47 | Bass { |
| 48 | level: i8, |
| 49 | }, |
| 50 | CreateEq { |
| 51 | name: String, |
| 52 | }, |
| 53 | RenameEq { |
| 54 | preset: String, |
| 55 | name: String, |
| 56 | }, |
| 57 | DeleteEq { |
| 58 | preset: String, |
| 59 | }, |
| 60 | } |
| 61 | |
| 62 | #[derive(Debug, Deserialize)] |
| 63 | struct Band { |
| 64 | hz: u16, |
| 65 | gain: i8, |
| 66 | } |
| 67 | |
| 68 | /// The worker command for a JSON command from the app. |
| 69 | pub fn command(json: &str) -> Result<Command> { |
| 70 | let request: Request = |
| 71 | serde_json::from_str(json).map_err(|e| Error::Usage(format!("not a command budctl knows: {e}")))?; |
| 72 | Ok(match request { |
| 73 | Request::Read => Command::Read, |
| 74 | Request::Noise { mode, level } => Command::Noise(noise_mode(&mode, level.as_deref())?), |
| 75 | Request::Switch { key, on } => Command::Switch(key, on), |
| 76 | Request::Eq { preset } => Command::Eq(preset), |
| 77 | Request::CustomEq { preset, bands } => Command::CustomEq( |
| 78 | preset, |
| 79 | bands |
| 80 | .into_iter() |
| 81 | .map(|band| EqBand { |
| 82 | hz: band.hz, |
| 83 | gain: band.gain, |
| 84 | }) |
| 85 | .collect(), |
| 86 | ), |
| 87 | Request::Touch { side, gesture, action } => Command::Touch { side, gesture, action }, |
| 88 | Request::HoldCycle { modes } => Command::HoldCycle(modes), |
| 89 | Request::Find { ring } => Command::Find(ring), |
| 90 | Request::Bass { level } => Command::Bass(level), |
| 91 | Request::CreateEq { name } => Command::CreateEq(name), |
| 92 | Request::RenameEq { preset, name } => Command::RenameEq(preset, name), |
| 93 | Request::DeleteEq { preset } => Command::DeleteEq(preset), |
| 94 | }) |
| 95 | } |
| 96 | |
| 97 | /// `anc` with no level is the deepest, as `budctl anc on` is. |
| 98 | fn noise_mode(mode: &str, level: Option<&str>) -> Result<NoiseMode> { |
| 99 | match (mode, level) { |
| 100 | ("off", None) => Ok(NoiseMode::Off), |
| 101 | ("transparency", None) => Ok(NoiseMode::Transparency), |
| 102 | ("anc", None | Some("deep")) => Ok(NoiseMode::Anc(AncLevel::Deep)), |
| 103 | ("anc", Some("medium")) => Ok(NoiseMode::Anc(AncLevel::Medium)), |
| 104 | ("anc", Some("light")) => Ok(NoiseMode::Anc(AncLevel::Light)), |
| 105 | _ => Err(Error::Usage(format!( |
| 106 | "no noise-control mode {mode:?} with level {level:?}" |
| 107 | ))), |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | /// A worker event, as the app reads it. |
| 112 | pub fn event(event: &Event) -> serde_json::Result<String> { |
| 113 | let (name, fields): (&str, Vec<(&str, Value)>) = match event { |
| 114 | Event::Found(name) => ("found", vec![("name", to_value(name)?)]), |
| 115 | Event::Read(reading) => ( |
| 116 | "read", |
| 117 | vec![ |
| 118 | ("status", status(&reading.status)?), |
| 119 | ("eq", to_value(&reading.eq)?), |
| 120 | ("touch", to_value(&reading.touch)?), |
| 121 | ("bass_range", to_value(reading.bass_range)?), |
| 122 | ("dual", to_value(&reading.dual)?), |
| 123 | ], |
| 124 | ), |
| 125 | Event::Noise(now) => ( |
| 126 | "noise", |
| 127 | vec![("mode", to_value(&now.mode)?), ("level", to_value(&now.level)?)], |
| 128 | ), |
| 129 | Event::Switch(switch) => ("switch", vec![("switch", to_value(switch)?)]), |
| 130 | Event::Eq(preset) => ("eq", vec![("preset", to_value(preset)?)]), |
| 131 | Event::CustomEq(preset) => ("custom-eq", vec![("preset", to_value(preset)?)]), |
| 132 | Event::Touch(gestures) => ("touch", vec![("gestures", to_value(gestures)?)]), |
| 133 | Event::HoldCycle(modes) => ("hold-cycle", vec![("modes", to_value(modes)?)]), |
| 134 | Event::Ringing(ring) => ("ringing", vec![("ring", Value::from(*ring))]), |
| 135 | Event::Bass(level) => ("bass", vec![("level", Value::from(*level))]), |
| 136 | Event::Presets(eq) => ("presets", vec![("eq", to_value(eq)?)]), |
| 137 | Event::Failed { kind, message } => ( |
| 138 | "failed", |
| 139 | vec![("kind", Value::from(kind_name(*kind))), ("message", to_value(message)?)], |
| 140 | ), |
| 141 | Event::Lost(message) => ("lost", vec![("message", to_value(message)?)]), |
| 142 | }; |
| 143 | let mut object = Map::new(); |
| 144 | object.insert("event".to_owned(), Value::from(name)); |
| 145 | for (key, value) in fields { |
| 146 | object.insert(key.to_owned(), value); |
| 147 | } |
| 148 | serde_json::to_string(&object) |
| 149 | } |
| 150 | |
| 151 | /// The status as `--json` writes it, except the switches: `--json` writes them as |
| 152 | /// `{"game": false}`, and the app needs their labels too. |
| 153 | fn status(status: &Status) -> serde_json::Result<Value> { |
| 154 | let mut value = to_value(status)?; |
| 155 | if let Some(object) = value.as_object_mut() { |
| 156 | object.insert("switches".to_owned(), to_value(&status.switches)?); |
| 157 | } |
| 158 | Ok(value) |
| 159 | } |
| 160 | |
| 161 | fn kind_name(kind: Kind) -> &'static str { |
| 162 | match kind { |
| 163 | Kind::Usage => "usage", |
| 164 | Kind::NotFound => "not-found", |
| 165 | Kind::Busy => "busy", |
| 166 | Kind::Failed => "failed", |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | /// A paired device, as the app lists it. |
| 171 | #[derive(Debug, Deserialize)] |
| 172 | struct Paired { |
| 173 | address: String, |
| 174 | name: String, |
| 175 | alias: String, |
| 176 | connected: bool, |
| 177 | /// Service UUIDs, lowercase and hyphenated, as Android cached them from the buds' SDP record. |
| 178 | uuids: Vec<String>, |
| 179 | } |
| 180 | |
| 181 | /// The paired devices, from the app's list. |
| 182 | pub fn paired_devices(json: &str) -> Result<Vec<PairedDevice>> { |
| 183 | let list: Vec<Paired> = serde_json::from_str(json).map_err(|e| app_error("reading the app's device list", e))?; |
| 184 | list.into_iter() |
| 185 | .map(|paired| { |
| 186 | Ok(PairedDevice { |
| 187 | address: paired |
| 188 | .address |
| 189 | .parse() |
| 190 | .map_err(|e| app_error("reading the app's device list", e))?, |
| 191 | name: paired.name, |
| 192 | alias: paired.alias, |
| 193 | connected: paired.connected, |
| 194 | uuids: paired.uuids, |
| 195 | }) |
| 196 | }) |
| 197 | .collect() |
| 198 | } |
| 199 | |
| 200 | /// Why the app could not open the link. |
| 201 | #[derive(Debug, Deserialize)] |
| 202 | struct Failure { |
| 203 | /// `busy`, `not-connected`, `timeout` or `failed`. |
| 204 | kind: String, |
| 205 | message: String, |
| 206 | } |
| 207 | |
| 208 | /// The error for the app's account of a failed connect (docs/android.md#bluetooth). |
| 209 | pub fn connect_failure(json: &str) -> Error { |
| 210 | const CONTEXT: &str = "connecting to the control channel"; |
| 211 | match serde_json::from_str::<Failure>(json) { |
| 212 | Ok(failure) => match failure.kind.as_str() { |
| 213 | "busy" => Error::ChannelBusy(failure.message), |
| 214 | "not-connected" => Error::NotConnected(failure.message), |
| 215 | "timeout" => Error::Timeout(failure.message), |
| 216 | _ => app_error(CONTEXT, failure.message), |
| 217 | }, |
| 218 | Err(e) => app_error(CONTEXT, e), |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | #[cfg(test)] |
| 223 | mod tests { |
| 224 | use super::*; |
| 225 | use budctl_core::status::{NoiseControl, Switch}; |
| 226 | use budctl_core::worker::Reading; |
| 227 | |
| 228 | #[test] |
| 229 | fn commands_parse_into_worker_commands() { |
| 230 | assert!(matches!(command(r#"{"command": "read"}"#), Ok(Command::Read))); |
| 231 | assert!(matches!( |
| 232 | command(r#"{"command": "noise", "mode": "anc"}"#), |
| 233 | Ok(Command::Noise(NoiseMode::Anc(AncLevel::Deep))) |
| 234 | )); |
| 235 | assert!(matches!( |
| 236 | command(r#"{"command": "noise", "mode": "anc", "level": "light"}"#), |
| 237 | Ok(Command::Noise(NoiseMode::Anc(AncLevel::Light))) |
| 238 | )); |
| 239 | assert!(matches!( |
| 240 | command(r#"{"command": "switch", "key": "game", "on": true}"#), |
| 241 | Ok(Command::Switch(key, true)) if key == "game" |
| 242 | )); |
| 243 | let Ok(Command::CustomEq(preset, bands)) = |
| 244 | command(r#"{"command": "custom-eq", "preset": "custom-4", "bands": [{"hz": 50, "gain": -3}]}"#) |
| 245 | else { |
| 246 | panic!("a custom EQ command expected"); |
| 247 | }; |
| 248 | assert_eq!(preset, "custom-4"); |
| 249 | assert_eq!(bands, [EqBand { hz: 50, gain: -3 }]); |
| 250 | assert!(matches!( |
| 251 | command(r#"{"command": "hold-cycle", "modes": ["off", "anc"]}"#), |
| 252 | Ok(Command::HoldCycle(modes)) if modes == ["off", "anc"] |
| 253 | )); |
| 254 | } |
| 255 | |
| 256 | #[test] |
| 257 | fn bad_commands_are_usage_errors() { |
| 258 | for json in [ |
| 259 | r#"{"command": "reboot"}"#, |
| 260 | r#"{"command": "noise", "mode": "loud"}"#, |
| 261 | r#"{"command": "noise", "mode": "off", "level": "deep"}"#, |
| 262 | r#"{"command": "bass", "level": 300}"#, |
| 263 | "not json", |
| 264 | ] { |
| 265 | assert_eq!(command(json).err().map(|e| e.kind()), Some(Kind::Usage), "{json}"); |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | fn parse(event: &Event) -> Value { |
| 270 | serde_json::from_str(&super::event(event).unwrap()).unwrap() |
| 271 | } |
| 272 | |
| 273 | #[test] |
| 274 | fn events_are_tagged_objects() { |
| 275 | let noise = parse(&Event::Noise(NoiseControl { |
| 276 | mode: "anc".to_owned(), |
| 277 | level: Some("deep".to_owned()), |
| 278 | })); |
| 279 | assert_eq!( |
| 280 | noise, |
| 281 | serde_json::json!({"event": "noise", "mode": "anc", "level": "deep"}) |
| 282 | ); |
| 283 | |
| 284 | let failed = parse(&Event::Failed { |
| 285 | kind: Kind::Busy, |
| 286 | message: "control channel busy".to_owned(), |
| 287 | }); |
| 288 | assert_eq!( |
| 289 | failed, |
| 290 | serde_json::json!({"event": "failed", "kind": "busy", "message": "control channel busy"}) |
| 291 | ); |
| 292 | assert_eq!( |
| 293 | parse(&Event::Ringing(true)), |
| 294 | serde_json::json!({"event": "ringing", "ring": true}) |
| 295 | ); |
| 296 | } |
| 297 | |
| 298 | #[test] |
| 299 | fn a_reading_keeps_the_switch_labels() { |
| 300 | let game = Switch { |
| 301 | key: "game", |
| 302 | label: "Game mode", |
| 303 | on: true, |
| 304 | }; |
| 305 | let reading = Reading { |
| 306 | status: Status { |
| 307 | switches: vec![game], |
| 308 | ..Status::default() |
| 309 | }, |
| 310 | eq: None, |
| 311 | touch: None, |
| 312 | bass_range: Some((-5, 5)), |
| 313 | dual: None, |
| 314 | }; |
| 315 | let read = parse(&Event::Read(Box::new(reading))); |
| 316 | assert_eq!(read["event"], "read"); |
| 317 | assert_eq!( |
| 318 | read["status"]["switches"], |
| 319 | serde_json::json!([{"key": "game", "label": "Game mode", "on": true}]) |
| 320 | ); |
| 321 | assert_eq!(read["bass_range"], serde_json::json!([-5, 5])); |
| 322 | assert_eq!(read["eq"], Value::Null); |
| 323 | } |
| 324 | |
| 325 | #[test] |
| 326 | fn paired_devices_come_from_the_app_list() { |
| 327 | let list = paired_devices( |
| 328 | r#"[{"address": "00:11:22:aa:bb:cc", "name": "realme Buds T310", "alias": "Buds", |
| 329 | "connected": true, "uuids": ["0000079a-d102-11e1-9b23-00025b00a5a5"]}]"#, |
| 330 | ) |
| 331 | .unwrap(); |
| 332 | let [buds] = list.as_slice() else { |
| 333 | panic!("one device expected"); |
| 334 | }; |
| 335 | assert_eq!(buds.address.to_string(), "00:11:22:AA:BB:CC"); |
| 336 | assert_eq!(buds.alias, "Buds"); |
| 337 | assert!(buds.connected); |
| 338 | |
| 339 | assert!( |
| 340 | paired_devices(r#"[{"address": "nope", "name": "", "alias": "", "connected": false, "uuids": []}]"#) |
| 341 | .is_err() |
| 342 | ); |
| 343 | assert!(paired_devices("{}").is_err()); |
| 344 | } |
| 345 | |
| 346 | #[test] |
| 347 | fn connect_failures_keep_their_kind() { |
| 348 | let kind = |json: &str| connect_failure(json).kind(); |
| 349 | assert_eq!(kind(r#"{"kind": "busy", "message": "refused"}"#), Kind::Busy); |
| 350 | assert_eq!(kind(r#"{"kind": "not-connected", "message": "off"}"#), Kind::NotFound); |
| 351 | assert_eq!(kind(r#"{"kind": "timeout", "message": "8 s"}"#), Kind::Failed); |
| 352 | assert_eq!(kind(r#"{"kind": "failed", "message": "?"}"#), Kind::NotFound); |
| 353 | assert_eq!(kind("garbage"), Kind::NotFound); |
| 354 | assert!( |
| 355 | connect_failure(r#"{"kind": "busy", "message": "refused"}"#) |
| 356 | .to_string() |
| 357 | .contains("is another app connected?") |
| 358 | ); |
| 359 | } |
| 360 | } |