Skip to main content

switchbot_cli/
cli.rs

1use std::{collections::HashMap, future::Future, io::stdout, iter::zip};
2
3use itertools::Itertools;
4use switchbot_api::{CommandRequest, Device, DeviceList, Help, SwitchBot};
5
6use crate::{Args, UserInput};
7
8#[derive(Debug, Default)]
9pub struct Cli {
10    args: Args,
11    switch_bot: SwitchBot,
12    current_device_indexes: Vec<usize>,
13    help: Option<Help>,
14}
15
16impl Cli {
17    pub fn new_from_args() -> Self {
18        Self {
19            args: Args::new_from_args(),
20            ..Default::default()
21        }
22    }
23
24    #[cfg(test)]
25    fn new_for_test(n_devices: usize) -> Self {
26        Self {
27            switch_bot: SwitchBot::new_for_test(n_devices),
28            ..Default::default()
29        }
30    }
31
32    fn devices(&self) -> &DeviceList {
33        self.switch_bot.devices()
34    }
35
36    fn has_current_device(&self) -> bool {
37        !self.current_device_indexes.is_empty()
38    }
39
40    fn num_current_devices(&self) -> usize {
41        self.current_device_indexes.len()
42    }
43
44    fn current_devices_as<'a, T, F>(&'a self, f: F) -> impl Iterator<Item = T> + 'a
45    where
46        F: Fn(usize) -> T + 'a,
47    {
48        self.current_device_indexes
49            .iter()
50            .map(move |&index| f(index))
51    }
52
53    fn current_devices(&self) -> impl Iterator<Item = &Device> {
54        self.current_devices_as(|index| &self.devices()[index])
55    }
56
57    fn current_devices_with_index(&self) -> impl Iterator<Item = (usize, &Device)> {
58        self.current_devices_as(|index| (index, &self.devices()[index]))
59    }
60
61    fn first_current_device(&self) -> &Device {
62        &self.devices()[self.current_device_indexes[0]]
63    }
64
65    async fn ensure_devices(&mut self) -> anyhow::Result<()> {
66        if self.devices().is_empty() {
67            self.switch_bot = self.args.create_switch_bot()?;
68            self.switch_bot.load_devices().await?;
69            log::debug!("ensure_devices: {} devices", self.devices().len());
70        }
71        Ok(())
72    }
73
74    pub async fn run(&mut self) -> anyhow::Result<()> {
75        self.args.process()?;
76        self.run_core().await?;
77        self.args.save()?;
78        Ok(())
79    }
80
81    async fn run_core(&mut self) -> anyhow::Result<()> {
82        let mut is_interactive = true;
83        if !self.args.alias_updates.is_empty() {
84            self.args.aliases.print();
85            is_interactive = false;
86        }
87
88        if !self.args.commands.is_empty() {
89            self.ensure_devices().await?;
90            self.execute_args(&self.args.commands.clone()).await?;
91        } else if is_interactive {
92            self.ensure_devices().await?;
93            self.run_interactive().await?;
94        }
95        Ok(())
96    }
97
98    async fn run_interactive(&mut self) -> anyhow::Result<()> {
99        let mut input = UserInput::new();
100        self.print_devices();
101        loop {
102            input.set_prompt(if self.has_current_device() {
103                "Command> "
104            } else {
105                "Device> "
106            });
107
108            let input_text = input.read_line()?;
109            match input_text {
110                "q" => break,
111                "" => {
112                    if self.has_current_device() {
113                        self.current_device_indexes.clear();
114                        self.print_devices();
115                        continue;
116                    }
117                    break;
118                }
119                _ => match self.execute(input_text).await {
120                    Ok(true) => self.print_devices(),
121                    Ok(false) => {}
122                    Err(error) => log::error!("{error}"),
123                },
124            }
125        }
126        Ok(())
127    }
128
129    fn print_devices(&self) {
130        if !self.has_current_device() {
131            self.print_all_devices();
132            return;
133        }
134
135        if self.current_device_indexes.len() >= 2 {
136            self.print_devices_with_index(self.current_devices_with_index());
137            return;
138        }
139
140        let device = self.first_current_device();
141        print!("{device:#}");
142    }
143
144    fn print_all_devices(&self) {
145        self.print_devices_with_index(self.devices().iter().enumerate());
146    }
147
148    fn print_devices_with_index<'a>(&self, iter: impl IntoIterator<Item = (usize, &'a Device)>) {
149        let reverse_aliases = self.args.aliases.reverse_map();
150        for (i, device) in iter {
151            self.print_device(device, i, &reverse_aliases);
152        }
153    }
154
155    fn print_device(
156        &self,
157        device: &Device,
158        index: usize,
159        reverse_aliases: &HashMap<&str, Vec<&str>>,
160    ) {
161        let index = index + 1;
162        let mut aliases: Vec<&str> = Vec::new();
163        if let Some(list) = reverse_aliases.get(index.to_string().as_str()) {
164            aliases.extend(list);
165        }
166        if let Some(list) = reverse_aliases.get(device.device_id()) {
167            aliases.extend(list);
168        }
169        if !aliases.is_empty() {
170            aliases.sort();
171            println!("{index}: {}={device}", aliases.iter().join("="));
172        } else {
173            println!("{index}: {device}");
174        }
175    }
176
177    const COMMAND_URL: &str =
178        "https://github.com/OpenWonderLabs/SwitchBotAPI#send-device-control-commands";
179    const COMMAND_IR_URL: &str = "https://github.com/OpenWonderLabs/SwitchBotAPI#command-set-for-virtual-infrared-remote-devices";
180
181    async fn print_help(&mut self) -> anyhow::Result<()> {
182        if self.help.is_none() {
183            self.help = Some(Help::load().await?);
184        }
185        let device = self.first_current_device();
186        let command_helps = self.help.as_ref().unwrap().command_helps(device);
187        let help_url = if device.is_remote() {
188            Self::COMMAND_IR_URL
189        } else {
190            Self::COMMAND_URL
191        };
192        if command_helps.is_empty() {
193            anyhow::bail!(
194                r#"No help for "{}". Please see {} for more information"#,
195                device.device_type_or_remote_type(),
196                help_url
197            )
198        }
199        for command_help in command_helps {
200            println!("{command_help}");
201        }
202        println!("Please see {help_url} for more information");
203        Ok(())
204    }
205
206    async fn execute_args(&mut self, list: &[String]) -> anyhow::Result<()> {
207        for command in list {
208            self.execute(command).await?;
209        }
210        Ok(())
211    }
212
213    async fn execute(&mut self, text: &str) -> anyhow::Result<bool> {
214        if let Some(alias) = self.args.aliases.get(text) {
215            log::debug!(r#"alias: "{text}" -> "{alias}""#);
216            return self.execute_no_alias(&alias.clone()).await;
217        }
218        self.execute_no_alias(text).await
219    }
220
221    /// Returns `true` if the current devices are changed.
222    async fn execute_no_alias(&mut self, text: &str) -> anyhow::Result<bool> {
223        let set_device_result = self.set_current_devices(text);
224        if set_device_result.is_ok() {
225            return Ok(true);
226        }
227        if self.execute_global_builtin_command(text)? {
228            return Ok(false);
229        }
230        if self.has_current_device() {
231            if self.execute_if_expr(text).await? {
232                return Ok(false);
233            }
234            if text == "help" {
235                self.print_help().await?;
236                return Ok(false);
237            }
238            self.execute_command(text).await?;
239            return Ok(false);
240        }
241        Err(set_device_result.unwrap_err())
242    }
243
244    fn set_current_devices(&mut self, text: &str) -> anyhow::Result<()> {
245        self.current_device_indexes = self.parse_device_indexes(text)?;
246        log::debug!("current_device_indexes={:?}", self.current_device_indexes);
247        Ok(())
248    }
249
250    fn parse_device_indexes(&self, value: &str) -> anyhow::Result<Vec<usize>> {
251        let values = value.split(',');
252        let mut indexes: Vec<usize> = Vec::new();
253        for s in values {
254            if let Some(alias) = self.args.aliases.get(s) {
255                indexes.extend(self.parse_device_indexes(alias)?);
256                continue;
257            }
258            indexes.push(self.parse_device_index(s)?);
259        }
260        indexes = indexes.into_iter().unique().collect::<Vec<_>>();
261        Ok(indexes)
262    }
263
264    fn parse_device_index(&self, value: &str) -> anyhow::Result<usize> {
265        if let Ok(number) = value.parse::<usize>()
266            && number > 0
267            && number <= self.devices().len()
268        {
269            return Ok(number - 1);
270        }
271        self.devices()
272            .index_by_device_id(value)
273            .ok_or_else(|| anyhow::anyhow!("Not a valid device: \"{value}\""))
274    }
275
276    async fn execute_if_expr(&mut self, expr: &str) -> anyhow::Result<bool> {
277        assert!(self.has_current_device());
278        if let Some((condition, then_command, else_command)) = Self::parse_if_expr(expr) {
279            let (device, expr) = self.device_expr(condition);
280            device.update_status().await?;
281            let eval_result = device.eval_condition(expr)?;
282            let command = if eval_result {
283                then_command
284            } else {
285                else_command
286            };
287            log::debug!("if: {condition} is {eval_result}, execute {command}");
288            Box::pin(self.execute(command)).await?;
289            return Ok(true);
290        }
291        Ok(false)
292    }
293
294    fn parse_if_expr(text: &str) -> Option<(&str, &str, &str)> {
295        if let Some(text) = text.strip_prefix("if")
296            && let Some(sep) = text.chars().nth(0)
297        {
298            if sep.is_alphanumeric() {
299                return None;
300            }
301            let fields: Vec<&str> = text[1..].split_terminator(sep).collect();
302            match fields.len() {
303                2 => return Some((fields[0], fields[1], "")),
304                3 => return Some((fields[0], fields[1], fields[2])),
305                _ => {}
306            }
307        }
308        None
309    }
310
311    fn device_expr<'a>(&'a self, expr: &'a str) -> (&'a Device, &'a str) {
312        if let Some((device, expr)) = expr.split_once('.')
313            && let Ok(device_indexes) = self.parse_device_indexes(device)
314        {
315            return (&self.devices()[device_indexes[0]], expr);
316        }
317        (self.first_current_device(), expr)
318    }
319
320    fn execute_global_builtin_command(&mut self, text: &str) -> anyhow::Result<bool> {
321        if text == "devices" {
322            self.print_all_devices();
323            return Ok(true);
324        }
325        if text == "alias" {
326            self.args.aliases.print();
327            return Ok(true);
328        }
329        if let Some(rest) = text.strip_prefix("alias ") {
330            let rest = rest.trim();
331            if rest.is_empty() {
332                self.args.aliases.print();
333            } else {
334                self.args.aliases.update(rest);
335            }
336            return Ok(true);
337        }
338        Ok(false)
339    }
340
341    async fn execute_device_builtin_command(&self, text: &str) -> anyhow::Result<bool> {
342        assert!(self.has_current_device());
343        if text == "status" {
344            self.update_status("").await?;
345            return Ok(true);
346        }
347        if let Some(key) = text.strip_prefix("status.") {
348            self.update_status(key).await?;
349            return Ok(true);
350        }
351        Ok(false)
352    }
353
354    async fn execute_command(&self, text: &str) -> anyhow::Result<()> {
355        assert!(self.has_current_device());
356        if text.is_empty() {
357            return Ok(());
358        }
359        if self.execute_device_builtin_command(text).await? {
360            return Ok(());
361        }
362        let command = CommandRequest::from(text);
363        self.for_each_selected_device(|device| device.command(&command), |_| Ok(()))
364            .await?;
365        Ok(())
366    }
367
368    async fn update_status(&self, key: &str) -> anyhow::Result<()> {
369        self.for_each_selected_device(
370            |device: &Device| device.update_status(),
371            |device| {
372                if key.is_empty() {
373                    device.write_status_to(stdout())?;
374                } else if let Some(value) = device.status_by_key(key) {
375                    println!("{value}");
376                } else {
377                    log::error!(r#"No status key "{key}" for {device}"#);
378                }
379                Ok(())
380            },
381        )
382        .await?;
383        Ok(())
384    }
385
386    async fn for_each_selected_device<'a, 'b, FnAsync, Fut>(
387        &'a self,
388        fn_async: FnAsync,
389        fn_post: impl Fn(&Device) -> anyhow::Result<()>,
390    ) -> anyhow::Result<()>
391    where
392        FnAsync: Fn(&'a Device) -> Fut + Send + Sync,
393        Fut: Future<Output = anyhow::Result<()>> + Send + 'b,
394    {
395        assert!(self.has_current_device());
396
397        let results = if self.num_current_devices() < self.args.parallel_threshold {
398            log::debug!("for_each: sequential ({})", self.num_current_devices());
399            let mut results = Vec::with_capacity(self.num_current_devices());
400            for device in self.current_devices() {
401                results.push(fn_async(device).await);
402            }
403            results
404        } else {
405            log::debug!("for_each: parallel ({})", self.num_current_devices());
406            let (_, join_results) = async_scoped::TokioScope::scope_and_block(|s| {
407                for device in self.current_devices() {
408                    s.spawn(fn_async(device));
409                }
410            });
411            join_results
412                .into_iter()
413                .map(|result| result.unwrap_or_else(|error| Err(error.into())))
414                .collect()
415        };
416
417        let last_error_index = results.iter().rposition(|result| result.is_err());
418        for (i, (device, result)) in zip(self.current_devices(), results).enumerate() {
419            match result {
420                Ok(_) => fn_post(device)?,
421                Err(error) => {
422                    if i == last_error_index.unwrap() {
423                        return Err(error);
424                    }
425                    log::error!("{error}");
426                }
427            }
428        }
429        Ok(())
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn parse_device_indexes() {
439        let cli = Cli::new_for_test(10);
440        assert!(cli.parse_device_indexes("").is_err());
441        assert_eq!(cli.parse_device_indexes("4").unwrap(), vec![3]);
442        assert_eq!(cli.parse_device_indexes("device4").unwrap(), vec![3]);
443        assert_eq!(cli.parse_device_indexes("2,4").unwrap(), vec![1, 3]);
444        assert_eq!(cli.parse_device_indexes("2,device4").unwrap(), vec![1, 3]);
445        // The result should not be sorted.
446        assert_eq!(cli.parse_device_indexes("4,2").unwrap(), vec![3, 1]);
447        assert_eq!(cli.parse_device_indexes("device4,2").unwrap(), vec![3, 1]);
448        // The result should be unique.
449        assert_eq!(cli.parse_device_indexes("2,4,2").unwrap(), vec![1, 3]);
450        assert_eq!(cli.parse_device_indexes("4,2,4").unwrap(), vec![3, 1]);
451    }
452
453    #[test]
454    fn parse_device_indexes_alias() {
455        let mut cli = Cli::new_for_test(10);
456        cli.args.aliases.insert("k".into(), "3,5".into());
457        assert_eq!(cli.parse_device_indexes("k").unwrap(), vec![2, 4]);
458        assert_eq!(cli.parse_device_indexes("1,k,4").unwrap(), vec![0, 2, 4, 3]);
459        cli.args.aliases.insert("j".into(), "2,k".into());
460        assert_eq!(
461            cli.parse_device_indexes("1,j,4").unwrap(),
462            vec![0, 1, 2, 4, 3]
463        );
464        assert_eq!(cli.parse_device_indexes("1,j,5").unwrap(), vec![0, 1, 2, 4]);
465    }
466
467    #[test]
468    fn parse_if_expr() {
469        assert_eq!(Cli::parse_if_expr(""), None);
470        assert_eq!(Cli::parse_if_expr("a"), None);
471        assert_eq!(Cli::parse_if_expr("if"), None);
472        assert_eq!(Cli::parse_if_expr("if/a"), None);
473        assert_eq!(Cli::parse_if_expr("if/a/b"), Some(("a", "b", "")));
474        assert_eq!(Cli::parse_if_expr("if/a/b/c"), Some(("a", "b", "c")));
475        assert_eq!(Cli::parse_if_expr("if/a//c"), Some(("a", "", "c")));
476        // The separator can be any characters as long as they're consistent.
477        assert_eq!(Cli::parse_if_expr("if;a;b;c"), Some(("a", "b", "c")));
478        assert_eq!(Cli::parse_if_expr("if.a.b.c"), Some(("a", "b", "c")));
479        // But non-alphanumeric.
480        assert_eq!(Cli::parse_if_expr("ifXaXbXc"), None);
481    }
482
483    #[test]
484    fn command_alias() {
485        let mut cli = Cli::new_for_test(10);
486        assert_eq!(cli.args.aliases.len(), 0);
487
488        // Add alias
489        assert!(cli.execute_global_builtin_command("alias a=b").unwrap());
490        assert_eq!(cli.args.aliases.len(), 1);
491        assert_eq!(cli.args.aliases.get("a").unwrap(), "b");
492
493        // Update alias
494        assert!(cli.execute_global_builtin_command("alias a=c").unwrap());
495        assert_eq!(cli.args.aliases.len(), 1);
496        assert_eq!(cli.args.aliases.get("a").unwrap(), "c");
497
498        // Remove alias
499        assert!(cli.execute_global_builtin_command("alias a=").unwrap());
500        assert_eq!(cli.args.aliases.len(), 0);
501
502        // Print aliases (should return true but not change aliases)
503        assert!(cli.execute_global_builtin_command("alias").unwrap());
504        assert_eq!(cli.args.aliases.len(), 0);
505
506        // Remove non-existent alias
507        assert!(cli.execute_global_builtin_command("alias a=").unwrap());
508        assert_eq!(cli.args.aliases.len(), 0);
509
510        // Alias without '=' removes it (consistent with Args::update_alias)
511        cli.args.aliases.insert("a".into(), "b".into());
512        assert!(cli.execute_global_builtin_command("alias a").unwrap());
513        assert_eq!(cli.args.aliases.len(), 0);
514    }
515}