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 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 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.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 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}