Krolyxon
Commit 0f2d3e5

Branches

Refs →

budctlcratesbudctl-androidsrcjson.rs

382 lines · 13.2 KB · Rust

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