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#[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 pub fn command(&self) -> &CommandRequest {
29 &self.command
30 }
31
32 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#[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 pub async fn load() -> anyhow::Result<Self> {
79 let mut loader = HelpLoader::default();
80 loader.load().await?;
81 Ok(loader.help)
82 }
83
84 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 && let Some(commands) = self.commands.get(alias)
112 {
113 return commands;
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 if let Some(remote_type) = remote_type.strip_prefix("DIY ")
124 && let Some(commands) = self.commands_ir.get(remote_type)
125 {
126 return commands;
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.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 && let Some(columns) = Markdown::table_columns(line)
252 && columns[0] == "deviceType"
253 && let Some(device_type) = Markdown::em(columns[2])
254 {
255 self.add_device_alias(device_type);
256 }
257 }
258 Section::Commands | Section::CommandsIR => {
259 if self.update_device_type(line) {
260 return Ok(());
261 }
262 if let Some(columns) = Markdown::table_columns(line) {
263 if !self.in_command_table {
264 if columns.len() == 5 && columns[0] == "deviceType" {
265 self.in_command_table = true;
266 }
267 } else if !columns[0].starts_with('-') {
268 if !columns[0].is_empty() && self.command_device_type != columns[0] {
269 self.flush_command_help();
270 log::trace!("{:?}: {:?}", self.section, columns[0]);
271 self.command_device_type = columns[0].into();
272 }
273 assert!(!self.command_device_type.is_empty());
274 let command = CommandRequest {
275 command_type: columns[1].into(),
276 command: columns[2].into(),
277 parameter: columns[3].into(),
278 };
279 let help = CommandHelp {
280 command,
281 description: Markdown::new(columns[4]),
282 };
283 self.command_helps.push(help);
284 }
285 } else {
286 self.flush_command_help();
287 self.in_command_table = false;
288 }
289 }
290 _ => {}
291 }
292 Ok(())
293 }
294
295 fn update_device_type(&mut self, line: &str) -> bool {
296 if let Some(text) = line.strip_prefix("##### ") {
297 self.device_name = text.trim().to_string();
298 return true;
299 }
300 false
301 }
302
303 fn add_device_alias(&mut self, device_type: &str) {
304 log::trace!("alias = {} -> {device_type}", self.device_name);
305 if self.device_name == device_type {
306 return;
307 }
308 self.help
309 .device_name_by_type
310 .insert(device_type.into(), self.device_name.clone());
311 }
312
313 fn flush_command_help(&mut self) {
314 if self.command_device_type.is_empty() || self.command_helps.is_empty() {
315 return;
316 }
317 let name = std::mem::take(&mut self.command_device_type);
318 log::trace!("flush_command: {:?}: {:?}", self.section, name);
319 let helps = std::mem::take(&mut self.command_helps);
320 if self.section == Section::CommandsIR {
321 let names: Vec<&str> = name.split(',').collect();
322 if names.len() > 1 {
323 for name in names {
324 self.add_command_help(name.trim().into(), helps.clone());
325 }
326 return;
327 }
328 }
329 self.add_command_help(name, helps);
330 }
331
332 fn add_command_help(&mut self, mut name: String, helps: Vec<CommandHelp>) {
333 if name == "Lock" && self.device_name == "Lock Pro" {
334 name = "Lock Pro".into();
336 }
337 let add_to = match self.section {
338 Section::Commands => &mut self.help.commands,
339 Section::CommandsIR => &mut self.help.commands_ir,
340 _ => panic!("Unexpected section {:?}", self.section),
341 };
342 match add_to.entry(name) {
343 std::collections::hash_map::Entry::Vacant(entry) => {
344 entry.insert(helps);
345 }
346 std::collections::hash_map::Entry::Occupied(mut entry) => {
347 entry.get_mut().extend(helps);
348 }
349 }
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 #[test]
358 fn section_update() {
359 let mut section = Section::default();
360 assert_eq!(section, Section::Initial);
361 assert!(section.update("## Devices"));
362 assert_eq!(section, Section::Devices);
363 }
364}