Krolyxon
Commit 3dfc07c

Branches

Refs →

budctlcratesbudctl-androidsrcjson.rs

360 lines · 12.3 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, 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 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)]
63struct Band {
64 hz: u16,
65 gain: i8,
66}
67
68/// The worker command for a JSON command from the app.
69pub 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.
98fn 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.
112pub 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.
153fn 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
161fn 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)]
172struct 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.
182pub 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)]
202struct 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).
209pub 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)]
223mod 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}