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