use std::{
collections::HashMap,
fmt::{Debug, Display, Formatter},
io::{BufRead, BufReader},
sync::LazyLock,
};
use crate::{CommandRequest, Device, Markdown};
#[derive(Clone, Debug)]
pub struct CommandHelp {
command: CommandRequest,
description: Markdown,
}
impl CommandHelp {
fn empty_vec() -> &'static Vec<CommandHelp> {
static EMPTY: Vec<CommandHelp> = Vec::new();
&EMPTY
}
pub fn command(&self) -> &CommandRequest {
&self.command
}
pub fn description(&self) -> &Markdown {
&self.description
}
}
impl Display for CommandHelp {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.command)?;
for description in self.description.to_string().split('\n') {
write!(f, "\n {description}")?;
}
Ok(())
}
}
#[derive(Default)]
pub struct Help {
commands: HashMap<String, Vec<CommandHelp>>,
commands_ir: HashMap<String, Vec<CommandHelp>>,
device_type_aliases: HashMap<String, Vec<String>>,
}
impl Debug for Help {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
writeln!(f, "commands:")?;
self.fmt_commands(&self.commands, f)?;
writeln!(f, "commands (IR):")?;
self.fmt_commands(&self.commands_ir, f)?;
writeln!(f, "aliases:")?;
for (device_type, aliases) in &self.device_type_aliases {
writeln!(f, "- {device_type} -> {aliases:?}")?;
}
Ok(())
}
}
impl Help {
pub async fn load() -> anyhow::Result<Self> {
let mut loader = HelpLoader::default();
loader.load().await?;
Ok(loader.help)
}
fn add_device_type_alias(&mut self, device_type: String, device_name: String) {
let aliases = self.device_type_aliases.entry(device_type).or_default();
if !aliases.contains(&device_name) {
aliases.push(device_name);
}
}
pub fn command_helps(&self, device: &Device) -> &Vec<CommandHelp> {
if device.is_remote() {
return self.command_helps_by_remote_type(device.remote_type());
}
self.command_helps_by_device_type(device.device_type())
}
fn command_helps_by_device_type(&self, device_type: &str) -> &Vec<CommandHelp> {
if let Some(commands) = self.commands.get(device_type) {
return commands;
}
if let Some(aliases) = self.device_type_aliases.get(device_type) {
for alias in aliases {
if let Some(commands) = self.commands.get(alias) {
return commands;
}
}
}
CommandHelp::empty_vec()
}
fn command_helps_by_remote_type(&self, remote_type: &str) -> &Vec<CommandHelp> {
if let Some(commands) = self.commands_ir.get(remote_type) {
return commands;
}
if let Some(remote_type) = remote_type.strip_prefix("DIY ")
&& let Some(commands) = self.commands_ir.get(remote_type)
{
return commands;
}
CommandHelp::empty_vec()
}
fn finalize(&mut self) {
self.add_device_type_alias("Standing Fan".into(), "Standing Circulator Fan".into());
const OTHER_KEY: &str = "Others";
if let Some(mut others) = self.commands_ir.remove(OTHER_KEY) {
for help in &mut others {
help.command.command_type = help.command.command_type.trim_matches('`').into();
}
for helps in self.commands_ir.values_mut() {
for help in &others {
helps.push(help.clone());
}
}
}
const ALL_KEY: &str = "All home appliance types except Others";
if let Some(all) = self.commands_ir.remove(ALL_KEY) {
for helps in self.commands_ir.values_mut() {
for (i, help) in all.iter().enumerate() {
helps.insert(i, help.clone());
}
}
}
}
fn fmt_commands(
&self,
commands: &HashMap<String, Vec<CommandHelp>>,
f: &mut Formatter<'_>,
) -> std::fmt::Result {
for (device_type, helps) in commands {
writeln!(f, "* {device_type}")?;
for help in helps {
writeln!(f, " - {help}")?;
}
}
Ok(())
}
}
#[derive(Copy, Clone, Debug, Default, PartialEq)]
enum Section {
#[default]
Initial,
Devices,
Status,
Commands,
CommandsIR,
Scenes,
}
impl Section {
fn update(&mut self, line: &str) -> bool {
static SECTIONS: LazyLock<HashMap<&str, Section>> = LazyLock::new(|| {
HashMap::from([
("## Devices", Section::Devices),
("### Get device status", Section::Status),
("### Send device control commands", Section::Commands),
(
"#### Command set for virtual infrared remote devices",
Section::CommandsIR,
),
("## Scenes", Section::Scenes),
])
});
if let Some(s) = SECTIONS.get(line) {
log::debug!("section: {self:?} -> {s:?}");
*self = *s;
return true;
}
false
}
}
#[derive(Debug, Default)]
struct HelpLoader {
help: Help,
section: Section,
device_name: String,
in_command_table: bool,
command_device_type: String,
command_helps: Vec<CommandHelp>,
}
impl HelpLoader {
const URL: &str =
"https://raw.githubusercontent.com/OpenWonderLabs/SwitchBotAPI/refs/heads/main/README.md";
pub async fn load(&mut self) -> anyhow::Result<()> {
let response = reqwest::get(Self::URL).await?.error_for_status()?;
let body = response.bytes().await?;
let reader = BufReader::new(body.as_ref());
self.read_lines(reader.lines())?;
self.help.finalize();
log::trace!("{:?}", self.help);
Ok(())
}
fn read_lines(
&mut self,
lines: impl Iterator<Item = std::io::Result<String>>,
) -> anyhow::Result<()> {
for line_result in lines {
let line_str = line_result?;
let line = line_str.trim();
self.read_line(line)?;
}
Ok(())
}
fn read_line(&mut self, line: &str) -> anyhow::Result<()> {
if self.section.update(line) {
return Ok(());
}
match self.section {
Section::Devices => {
if self.update_device_type(line) {
return Ok(());
}
if !self.device_name.is_empty()
&& let Some(columns) = Markdown::table_columns(line)
&& columns[0] == "deviceType"
&& let Some(device_type) = Markdown::em(columns[2])
{
self.add_device_alias(device_type);
}
}
Section::Commands | Section::CommandsIR => {
if self.update_device_type(line) {
return Ok(());
}
if let Some(columns) = Markdown::table_columns(line) {
if !self.in_command_table {
if columns.len() == 5 && columns[0] == "deviceType" {
self.in_command_table = true;
}
} else if !columns[0].starts_with('-') {
if !columns[0].is_empty() && self.command_device_type != columns[0] {
self.flush_command_help();
log::trace!("{:?}: {:?}", self.section, columns[0]);
self.command_device_type = columns[0].into();
}
assert!(!self.command_device_type.is_empty());
let command = CommandRequest {
command_type: columns[1].into(),
command: columns[2].into(),
parameter: columns[3].into(),
};
let help = CommandHelp {
command,
description: Markdown::new(columns[4]),
};
self.command_helps.push(help);
}
} else {
self.flush_command_help();
self.in_command_table = false;
}
}
_ => {}
}
Ok(())
}
fn update_device_type(&mut self, line: &str) -> bool {
if let Some(text) = line.strip_prefix("##### ") {
self.device_name = text.trim().to_string();
return true;
}
false
}
fn add_device_alias(&mut self, device_type: &str) {
log::trace!("alias = {} -> {device_type}", self.device_name);
if self.device_name == device_type {
return;
}
self.help
.add_device_type_alias(device_type.into(), self.device_name.clone());
}
fn flush_command_help(&mut self) {
if self.command_device_type.is_empty() || self.command_helps.is_empty() {
return;
}
let name = std::mem::take(&mut self.command_device_type);
log::trace!("flush_command: {:?}: {:?}", self.section, name);
let helps = std::mem::take(&mut self.command_helps);
if self.section == Section::CommandsIR {
let names: Vec<&str> = name.split(',').collect();
if names.len() > 1 {
for name in names {
self.add_command_help(name.trim().into(), helps.clone());
}
return;
}
}
self.add_command_help(name, helps);
}
fn add_command_help(&mut self, mut name: String, helps: Vec<CommandHelp>) {
if name == "Lock" && self.device_name == "Lock Pro" {
name = "Lock Pro".into();
}
let add_to = match self.section {
Section::Commands => &mut self.help.commands,
Section::CommandsIR => &mut self.help.commands_ir,
_ => panic!("Unexpected section {:?}", self.section),
};
match add_to.entry(name) {
std::collections::hash_map::Entry::Vacant(entry) => {
entry.insert(helps);
}
std::collections::hash_map::Entry::Occupied(mut entry) => {
entry.get_mut().extend(helps);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn section_update() {
let mut section = Section::default();
assert_eq!(section, Section::Initial);
assert!(section.update("## Devices"));
assert_eq!(section, Section::Devices);
}
#[test]
fn multiple_aliases() {
let mut help = Help::default();
help.commands.insert(
"TargetDevice".into(),
vec![CommandHelp {
command: CommandRequest::default(),
description: Markdown::new("test"),
}],
);
help.add_device_type_alias("AliasType".into(), "NonExistentDevice".into());
help.add_device_type_alias("AliasType".into(), "TargetDevice".into());
let helps = help.command_helps_by_device_type("AliasType");
assert_eq!(helps.len(), 1);
}
}