Skip to main content

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_type_aliases: HashMap<String, Vec<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, aliases) in &self.device_type_aliases {
68            writeln!(f, "- {device_type} -> {aliases:?}")?;
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    /// Adds a device type alias.
85    fn add_device_type_alias(&mut self, device_type: String, device_name: String) {
86        let aliases = self.device_type_aliases.entry(device_type).or_default();
87        if !aliases.contains(&device_name) {
88            aliases.push(device_name);
89        }
90    }
91
92    /// Get a list of [`CommandHelp`] for a [`Device`].
93    /// Returns an empty `Vec` if no [`CommandHelp`]s are found.
94    ///
95    /// # Examples
96    /// ```no_run
97    /// # use switchbot_api::{Device, Help};
98    /// # async fn help(device: &Device) -> anyhow::Result<()> {
99    /// let help = Help::load().await?;
100    /// let command_helps = help.command_helps(device);
101    /// for command_help in command_helps {
102    ///   println!("{}", command_help);
103    /// }
104    /// # Ok(())
105    /// # }
106    /// ```
107    pub fn command_helps(&self, device: &Device) -> &Vec<CommandHelp> {
108        if device.is_remote() {
109            return self.command_helps_by_remote_type(device.remote_type());
110        }
111        self.command_helps_by_device_type(device.device_type())
112    }
113
114    fn command_helps_by_device_type(&self, device_type: &str) -> &Vec<CommandHelp> {
115        if let Some(commands) = self.commands.get(device_type) {
116            return commands;
117        }
118        if let Some(aliases) = self.device_type_aliases.get(device_type) {
119            for alias in aliases {
120                if let Some(commands) = self.commands.get(alias) {
121                    return commands;
122                }
123            }
124        }
125        CommandHelp::empty_vec()
126    }
127
128    fn command_helps_by_remote_type(&self, remote_type: &str) -> &Vec<CommandHelp> {
129        if let Some(commands) = self.commands_ir.get(remote_type) {
130            return commands;
131        }
132        // Some remotes have a "DIY " prefix. Try by removing it.
133        if let Some(remote_type) = remote_type.strip_prefix("DIY ")
134            && let Some(commands) = self.commands_ir.get(remote_type)
135        {
136            return commands;
137        }
138        CommandHelp::empty_vec()
139    }
140
141    fn finalize(&mut self) {
142        self.add_device_type_alias("Standing Fan".into(), "Standing Circulator Fan".into());
143
144        const OTHER_KEY: &str = "Others";
145        if let Some(mut others) = self.commands_ir.remove(OTHER_KEY) {
146            for help in &mut others {
147                help.command.command_type = help.command.command_type.trim_matches('`').into();
148            }
149            for helps in self.commands_ir.values_mut() {
150                for help in &others {
151                    helps.push(help.clone());
152                }
153            }
154        }
155
156        const ALL_KEY: &str = "All home appliance types except Others";
157        if let Some(all) = self.commands_ir.remove(ALL_KEY) {
158            for helps in self.commands_ir.values_mut() {
159                for (i, help) in all.iter().enumerate() {
160                    helps.insert(i, help.clone());
161                }
162            }
163        }
164    }
165
166    fn fmt_commands(
167        &self,
168        commands: &HashMap<String, Vec<CommandHelp>>,
169        f: &mut Formatter<'_>,
170    ) -> std::fmt::Result {
171        for (device_type, helps) in commands {
172            writeln!(f, "* {device_type}")?;
173            for help in helps {
174                writeln!(f, "  - {help}")?;
175            }
176        }
177        Ok(())
178    }
179}
180
181#[derive(Copy, Clone, Debug, Default, PartialEq)]
182enum Section {
183    #[default]
184    Initial,
185    Devices,
186    Status,
187    Commands,
188    CommandsIR,
189    Scenes,
190}
191
192impl Section {
193    fn update(&mut self, line: &str) -> bool {
194        static SECTIONS: LazyLock<HashMap<&str, Section>> = LazyLock::new(|| {
195            HashMap::from([
196                ("## Devices", Section::Devices),
197                ("### Get device status", Section::Status),
198                ("### Send device control commands", Section::Commands),
199                (
200                    "#### Command set for virtual infrared remote devices",
201                    Section::CommandsIR,
202                ),
203                ("## Scenes", Section::Scenes),
204            ])
205        });
206        if let Some(s) = SECTIONS.get(line) {
207            log::debug!("section: {self:?} -> {s:?}");
208            *self = *s;
209            return true;
210        }
211        false
212    }
213}
214
215#[derive(Debug, Default)]
216struct HelpLoader {
217    help: Help,
218    section: Section,
219    device_name: String,
220    in_command_table: bool,
221    command_device_type: String,
222    command_helps: Vec<CommandHelp>,
223}
224
225impl HelpLoader {
226    const URL: &str =
227        "https://raw.githubusercontent.com/OpenWonderLabs/SwitchBotAPI/refs/heads/main/README.md";
228
229    pub async fn load(&mut self) -> anyhow::Result<()> {
230        let response = reqwest::get(Self::URL).await?.error_for_status()?;
231        // let body = response.text().await?;
232        // let reader = BufReader::new(body.as_bytes());
233        let body = response.bytes().await?;
234        let reader = BufReader::new(body.as_ref());
235        self.read_lines(reader.lines())?;
236        self.help.finalize();
237        log::trace!("{:?}", self.help);
238        Ok(())
239    }
240
241    fn read_lines(
242        &mut self,
243        lines: impl Iterator<Item = std::io::Result<String>>,
244    ) -> anyhow::Result<()> {
245        for line_result in lines {
246            let line_str = line_result?;
247            let line = line_str.trim();
248            self.read_line(line)?;
249        }
250        Ok(())
251    }
252
253    fn read_line(&mut self, line: &str) -> anyhow::Result<()> {
254        if self.section.update(line) {
255            return Ok(());
256        }
257        match self.section {
258            Section::Devices => {
259                if self.update_device_type(line) {
260                    return Ok(());
261                }
262                if !self.device_name.is_empty()
263                    && let Some(columns) = Markdown::table_columns(line)
264                    && columns[0] == "deviceType"
265                    && let Some(device_type) = Markdown::em(columns[2])
266                {
267                    self.add_device_alias(device_type);
268                }
269            }
270            Section::Commands | Section::CommandsIR => {
271                if self.update_device_type(line) {
272                    return Ok(());
273                }
274                if let Some(columns) = Markdown::table_columns(line) {
275                    if !self.in_command_table {
276                        if columns.len() == 5 && columns[0] == "deviceType" {
277                            self.in_command_table = true;
278                        }
279                    } else if !columns[0].starts_with('-') {
280                        if !columns[0].is_empty() && self.command_device_type != columns[0] {
281                            self.flush_command_help();
282                            log::trace!("{:?}: {:?}", self.section, columns[0]);
283                            self.command_device_type = columns[0].into();
284                        }
285                        assert!(!self.command_device_type.is_empty());
286                        let command = CommandRequest {
287                            command_type: columns[1].into(),
288                            command: columns[2].into(),
289                            parameter: columns[3].into(),
290                        };
291                        let help = CommandHelp {
292                            command,
293                            description: Markdown::new(columns[4]),
294                        };
295                        self.command_helps.push(help);
296                    }
297                } else {
298                    self.flush_command_help();
299                    self.in_command_table = false;
300                }
301            }
302            _ => {}
303        }
304        Ok(())
305    }
306
307    fn update_device_type(&mut self, line: &str) -> bool {
308        if let Some(text) = line.strip_prefix("##### ") {
309            self.device_name = text.trim().to_string();
310            return true;
311        }
312        false
313    }
314
315    fn add_device_alias(&mut self, device_type: &str) {
316        log::trace!("alias = {} -> {device_type}", self.device_name);
317        if self.device_name == device_type {
318            return;
319        }
320        self.help
321            .add_device_type_alias(device_type.into(), self.device_name.clone());
322    }
323
324    fn flush_command_help(&mut self) {
325        if self.command_device_type.is_empty() || self.command_helps.is_empty() {
326            return;
327        }
328        let name = std::mem::take(&mut self.command_device_type);
329        log::trace!("flush_command: {:?}: {:?}", self.section, name);
330        let helps = std::mem::take(&mut self.command_helps);
331        if self.section == Section::CommandsIR {
332            let names: Vec<&str> = name.split(',').collect();
333            if names.len() > 1 {
334                for name in names {
335                    self.add_command_help(name.trim().into(), helps.clone());
336                }
337                return;
338            }
339        }
340        self.add_command_help(name, helps);
341    }
342
343    fn add_command_help(&mut self, mut name: String, helps: Vec<CommandHelp>) {
344        if name == "Lock" && self.device_name == "Lock Pro" {
345            // https://github.com/OpenWonderLabs/SwitchBotAPI/pull/413
346            name = "Lock Pro".into();
347        }
348        let add_to = match self.section {
349            Section::Commands => &mut self.help.commands,
350            Section::CommandsIR => &mut self.help.commands_ir,
351            _ => panic!("Unexpected section {:?}", self.section),
352        };
353        match add_to.entry(name) {
354            std::collections::hash_map::Entry::Vacant(entry) => {
355                entry.insert(helps);
356            }
357            std::collections::hash_map::Entry::Occupied(mut entry) => {
358                entry.get_mut().extend(helps);
359            }
360        }
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn section_update() {
370        let mut section = Section::default();
371        assert_eq!(section, Section::Initial);
372        assert!(section.update("## Devices"));
373        assert_eq!(section, Section::Devices);
374    }
375
376    #[test]
377    fn multiple_aliases() {
378        let mut help = Help::default();
379        help.commands.insert(
380            "TargetDevice".into(),
381            vec![CommandHelp {
382                command: CommandRequest::default(),
383                description: Markdown::new("test"),
384            }],
385        );
386        help.add_device_type_alias("AliasType".into(), "NonExistentDevice".into());
387        help.add_device_type_alias("AliasType".into(), "TargetDevice".into());
388        let helps = help.command_helps_by_device_type("AliasType");
389        assert_eq!(helps.len(), 1);
390    }
391}