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_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 pub async fn load() -> anyhow::Result<Self> {
79 let mut loader = HelpLoader::default();
80 loader.load().await?;
81 Ok(loader.help)
82 }
83
84 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 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 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.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 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}