switchbot_api/
help.rs

1use std::{
2    collections::HashMap,
3    fmt::{Debug, Display, Formatter},
4    io::{BufRead, BufReader},
5    sync::LazyLock,
6};
7
8use crate::{CommandRequest, Device, Markdown};
9
10/// Human-readable description of a [`CommandRequest`].
11///
12/// Please see [`Help::command_helps()`] for how to get this struct.
13#[derive(Clone, Debug)]
14pub struct CommandHelp {
15    command: CommandRequest,
16    description: Markdown,
17}
18
19impl CommandHelp {
20    fn empty_vec() -> &'static Vec<CommandHelp> {
21        static EMPTY: Vec<CommandHelp> = Vec::new();
22        &EMPTY
23    }
24
25    /// The [`CommandRequest`].
26    /// Note that this may contain human-readable text
27    /// and may not be able to send to the SwitchBot API directly.
28    pub fn command(&self) -> &CommandRequest {
29        &self.command
30    }
31
32    /// The human-readable description of the [`command()`][CommandHelp::command()].
33    pub fn description(&self) -> &Markdown {
34        &self.description
35    }
36}
37
38impl Display for CommandHelp {
39    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
40        write!(f, "{}", self.command)?;
41        for description in self.description.to_string().split('\n') {
42            write!(f, "\n    {}", description)?;
43        }
44        Ok(())
45    }
46}
47
48/// Load and parse the documentations at the [SwitchBot API].
49///
50/// Please see [`Help::command_helps()`] for an example.
51///
52/// [SwitchBot API]: https://github.com/OpenWonderLabs/SwitchBotAPI
53#[derive(Default)]
54pub struct Help {
55    commands: HashMap<String, Vec<CommandHelp>>,
56    commands_ir: HashMap<String, Vec<CommandHelp>>,
57    device_name_by_type: HashMap<String, String>,
58}
59
60impl Debug for Help {
61    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
62        writeln!(f, "commands:")?;
63        self.fmt_commands(&self.commands, f)?;
64        writeln!(f, "commands (IR):")?;
65        self.fmt_commands(&self.commands_ir, f)?;
66        writeln!(f, "aliases:")?;
67        for (device_type, device_name) in &self.device_name_by_type {
68            writeln!(f, "- {device_type} -> {device_name}")?;
69        }
70        Ok(())
71    }
72}
73
74impl Help {
75    /// Loads and parses the documentations from the [SwitchBot API].
76    ///
77    /// [SwitchBot API]: https://github.com/OpenWonderLabs/SwitchBotAPI
78    pub async fn load() -> anyhow::Result<Self> {
79        let mut loader = HelpLoader::default();
80        loader.load().await?;
81        Ok(loader.help)
82    }
83
84    /// Get a list of [`CommandHelp`] for a [`Device`].
85    /// Returns an empty `Vec` if no [`CommandHelp`]s are found.
86    ///
87    /// # Examples
88    /// ```no_run
89    /// # use switchbot_api::{Device, Help};
90    /// # async fn help(device: &Device) -> anyhow::Result<()> {
91    /// let help = Help::load().await?;
92    /// let command_helps = help.command_helps(device);
93    /// for command_help in command_helps {
94    ///   println!("{}", command_help);
95    /// }
96    /// # Ok(())
97    /// # }
98    /// ```
99    pub fn command_helps(&self, device: &Device) -> &Vec<CommandHelp> {
100        if device.is_remote() {
101            return self.command_helps_by_remote_type(device.remote_type());
102        }
103        self.command_helps_by_device_type(device.device_type())
104    }
105
106    fn command_helps_by_device_type(&self, device_type: &str) -> &Vec<CommandHelp> {
107        if let Some(commands) = self.commands.get(device_type) {
108            return commands;
109        }
110        if let Some(alias) = self.device_name_by_type.get(device_type) {
111            if let Some(commands) = self.commands.get(alias) {
112                return commands;
113            }
114        }
115        CommandHelp::empty_vec()
116    }
117
118    fn command_helps_by_remote_type(&self, remote_type: &str) -> &Vec<CommandHelp> {
119        if let Some(commands) = self.commands_ir.get(remote_type) {
120            return commands;
121        }
122        // Some remotes have a "DIY " prefix. Try by removing it.
123        if let Some(remote_type) = remote_type.strip_prefix("DIY ") {
124            if let Some(commands) = self.commands_ir.get(remote_type) {
125                return commands;
126            }
127        }
128        CommandHelp::empty_vec()
129    }
130
131    fn finalize(&mut self) {
132        const OTHER_KEY: &str = "Others";
133        if let Some(mut others) = self.commands_ir.remove(OTHER_KEY) {
134            for help in &mut others {
135                help.command.command_type = help.command.command_type.trim_matches('`').into();
136            }
137            for helps in self.commands_ir.values_mut() {
138                for help in &others {
139                    helps.push(help.clone());
140                }
141            }
142        }
143
144        const ALL_KEY: &str = "All home appliance types except Others";
145        if let Some(all) = self.commands_ir.remove(ALL_KEY) {
146            for helps in self.commands_ir.values_mut() {
147                for (i, help) in all.iter().enumerate() {
148                    helps.insert(i, help.clone());
149                }
150            }
151        }
152    }
153
154    fn fmt_commands(
155        &self,
156        commands: &HashMap<String, Vec<CommandHelp>>,
157        f: &mut Formatter<'_>,
158    ) -> std::fmt::Result {
159        for (device_type, helps) in commands {
160            writeln!(f, "* {device_type}")?;
161            for help in helps {
162                writeln!(f, "  - {}", help)?;
163            }
164        }
165        Ok(())
166    }
167}
168
169#[derive(Copy, Clone, Debug, Default, PartialEq)]
170enum Section {
171    #[default]
172    Initial,
173    Devices,
174    Status,
175    Commands,
176    CommandsIR,
177    Scenes,
178}
179
180impl Section {
181    fn update(&mut self, line: &str) -> bool {
182        static SECTIONS: LazyLock<HashMap<&str, Section>> = LazyLock::new(|| {
183            HashMap::from([
184                ("## Devices", Section::Devices),
185                ("### Get device status", Section::Status),
186                ("### Send device control commands", Section::Commands),
187                (
188                    "#### Command set for virtual infrared remote devices",
189                    Section::CommandsIR,
190                ),
191                ("## Scenes", Section::Scenes),
192            ])
193        });
194        if let Some(s) = SECTIONS.get(line) {
195            log::debug!("section: {:?} -> {:?}", self, s);
196            *self = *s;
197            return true;
198        }
199        false
200    }
201}
202
203#[derive(Debug, Default)]
204struct HelpLoader {
205    help: Help,
206    section: Section,
207    device_name: String,
208    in_command_table: bool,
209    command_device_type: String,
210    command_helps: Vec<CommandHelp>,
211}
212
213impl HelpLoader {
214    const URL: &str =
215        "https://raw.githubusercontent.com/OpenWonderLabs/SwitchBotAPI/refs/heads/main/README.md";
216
217    pub async fn load(&mut self) -> anyhow::Result<()> {
218        let response = reqwest::get(Self::URL).await?.error_for_status()?;
219        // let body = response.text().await?;
220        // let reader = BufReader::new(body.as_bytes());
221        let body = response.bytes().await?;
222        let reader = BufReader::new(body.as_ref());
223        self.read_lines(reader.lines())?;
224        self.help.finalize();
225        log::trace!("{:?}", self.help);
226        Ok(())
227    }
228
229    fn read_lines(
230        &mut self,
231        lines: impl Iterator<Item = std::io::Result<String>>,
232    ) -> anyhow::Result<()> {
233        for line_result in lines {
234            let line_str = line_result?;
235            let line = line_str.trim();
236            self.read_line(line)?;
237        }
238        Ok(())
239    }
240
241    fn read_line(&mut self, line: &str) -> anyhow::Result<()> {
242        if self.section.update(line) {
243            return Ok(());
244        }
245        match self.section {
246            Section::Devices => {
247                if self.update_device_type(line) {
248                    return Ok(());
249                }
250                if !self.device_name.is_empty() {
251                    if let Some(columns) = Markdown::table_columns(line) {
252                        if columns[0] == "deviceType" {
253                            if let Some(device_type) = Markdown::em(columns[2]) {
254                                self.add_device_alias(device_type);
255                            }
256                        }
257                    }
258                }
259            }
260            Section::Commands | Section::CommandsIR => {
261                if self.update_device_type(line) {
262                    return Ok(());
263                }
264                if let Some(columns) = Markdown::table_columns(line) {
265                    if !self.in_command_table {
266                        if columns.len() == 5 && columns[0] == "deviceType" {
267                            self.in_command_table = true;
268                        }
269                    } else if !columns[0].starts_with('-') {
270                        if !columns[0].is_empty() && self.command_device_type != columns[0] {
271                            self.flush_command_help();
272                            log::trace!("{:?}: {:?}", self.section, columns[0]);
273                            self.command_device_type = columns[0].into();
274                        }
275                        assert!(!self.command_device_type.is_empty());
276                        let command = CommandRequest {
277                            command_type: columns[1].into(),
278                            command: columns[2].into(),
279                            parameter: columns[3].into(),
280                        };
281                        let help = CommandHelp {
282                            command,
283                            description: Markdown::new(columns[4]),
284                        };
285                        self.command_helps.push(help);
286                    }
287                } else {
288                    self.flush_command_help();
289                    self.in_command_table = false;
290                }
291            }
292            _ => {}
293        }
294        Ok(())
295    }
296
297    fn update_device_type(&mut self, line: &str) -> bool {
298        if let Some(text) = line.strip_prefix("##### ") {
299            self.device_name = text.trim().to_string();
300            return true;
301        }
302        false
303    }
304
305    fn add_device_alias(&mut self, device_type: &str) {
306        log::trace!("alias = {} -> {device_type}", self.device_name);
307        if self.device_name == device_type {
308            return;
309        }
310        self.help
311            .device_name_by_type
312            .insert(device_type.into(), self.device_name.clone());
313    }
314
315    fn flush_command_help(&mut self) {
316        if self.command_device_type.is_empty() || self.command_helps.is_empty() {
317            return;
318        }
319        let name = std::mem::take(&mut self.command_device_type);
320        log::trace!("flush_command: {:?}: {:?}", self.section, name);
321        let helps = std::mem::take(&mut self.command_helps);
322        if self.section == Section::CommandsIR {
323            let names: Vec<&str> = name.split(',').collect();
324            if names.len() > 1 {
325                for name in names {
326                    self.add_command_help(name.trim().into(), helps.clone());
327                }
328                return;
329            }
330        }
331        self.add_command_help(name, helps);
332    }
333
334    fn add_command_help(&mut self, mut name: String, helps: Vec<CommandHelp>) {
335        if name == "Lock" && self.device_name == "Lock Pro" {
336            // https://github.com/OpenWonderLabs/SwitchBotAPI/pull/413
337            name = "Lock Pro".into();
338        }
339        let add_to = match self.section {
340            Section::Commands => &mut self.help.commands,
341            Section::CommandsIR => &mut self.help.commands_ir,
342            _ => panic!("Unexpected section {:?}", self.section),
343        };
344        match add_to.entry(name) {
345            std::collections::hash_map::Entry::Vacant(entry) => {
346                entry.insert(helps);
347            }
348            std::collections::hash_map::Entry::Occupied(mut entry) => {
349                entry.get_mut().extend(helps);
350            }
351        }
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn section_update() {
361        let mut section = Section::default();
362        assert_eq!(section, Section::Initial);
363        assert!(section.update("## Devices"));
364        assert_eq!(section, Section::Devices);
365    }
366}