switchbot_api/
switch_bot_service.rs

1use base64::{Engine as _, engine::general_purpose::STANDARD};
2use hmac::{Hmac, Mac};
3use sha2::Sha256;
4use std::{rc::Rc, time::SystemTime};
5use uuid::Uuid;
6
7use super::*;
8
9#[derive(Debug, Default)]
10pub struct SwitchBotService {
11    client: reqwest::Client,
12    token: String,
13    secret: String,
14}
15
16impl SwitchBotService {
17    const HOST: &str = "https://api.switch-bot.com";
18
19    pub fn new(token: &str, secret: &str) -> Rc<Self> {
20        Rc::new(SwitchBotService {
21            client: reqwest::Client::new(),
22            token: token.to_string(),
23            secret: secret.to_string(),
24        })
25    }
26
27    pub async fn load_devices(self: &Rc<SwitchBotService>) -> anyhow::Result<DeviceList> {
28        let url = format!("{}/v1.1/devices", Self::HOST);
29        // let url = format!("https://www.google.com");
30        let json: serde_json::Value = self
31            .add_headers(self.client.get(url))?
32            // .header("Content-Type", "application/json")
33            .send()
34            .await?
35            .json()
36            .await?;
37        log::trace!("devices.json: {json:#?}");
38        let response: SwitchBotResponse<DeviceListResponse> = serde_json::from_value(json)?;
39        // log::trace!("devices: {response:#?}");
40        let mut devices = response.body.deviceList;
41        devices.extend(response.body.infraredRemoteList);
42        for device in devices.iter_mut() {
43            device.set_service(Rc::clone(self));
44        }
45        Ok(devices)
46    }
47
48    pub(crate) async fn command(
49        &self,
50        device_id: &str,
51        command: &CommandRequest,
52    ) -> anyhow::Result<()> {
53        let url = format!("{}/v1.1/devices/{device_id}/commands", Self::HOST);
54        let body = serde_json::to_value(command)?;
55        log::debug!("command.request: {body}");
56        let json: serde_json::Value = self
57            .add_headers(self.client.post(url))?
58            .header("Content-Type", "application/json")
59            .json(&body)
60            .send()
61            .await?
62            .json()
63            .await?;
64
65        log::trace!("command.response: {json}");
66        let response: SwitchBotError = serde_json::from_value(json)?;
67        // All statusCode other than 100 looks like errors.
68        // https://github.com/OpenWonderLabs/SwitchBotAPI?tab=readme-ov-file#errors
69        if response.status_code != 100 {
70            return Err(response.into());
71        }
72        Ok(())
73    }
74
75    fn add_headers(
76        &self,
77        builder: reqwest::RequestBuilder,
78    ) -> anyhow::Result<reqwest::RequestBuilder> {
79        let duration_since_epoch = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?;
80        let t = duration_since_epoch.as_millis().to_string();
81        let nonce = Uuid::new_v4().to_string();
82
83        let mut mac = Hmac::<Sha256>::new_from_slice(self.secret.as_bytes())?;
84        mac.update(self.token.as_bytes());
85        mac.update(t.as_bytes());
86        mac.update(nonce.as_bytes());
87        let result = mac.finalize();
88        let sign = STANDARD.encode(result.into_bytes());
89
90        Ok(builder
91            .header("Authorization", self.token.clone())
92            .header("t", t)
93            .header("sign", sign)
94            .header("nonce", nonce))
95    }
96}
97
98#[derive(Debug, serde::Deserialize)]
99#[allow(non_snake_case)]
100pub struct SwitchBotResponse<T> {
101    #[allow(dead_code)]
102    pub statusCode: u16,
103    #[allow(dead_code)]
104    pub message: String,
105    pub body: T,
106}
107
108#[derive(Debug, serde::Deserialize)]
109#[allow(non_snake_case)]
110pub struct DeviceListResponse {
111    pub deviceList: DeviceList,
112    pub infraredRemoteList: DeviceList,
113}
114
115/// A command request to send to the [SwitchBot API].
116///
117/// For more details of each field, please refer to the [SwitchBot
118/// documentation about device control commands][send-device-control-commands].
119///
120/// # Examples
121/// ```
122/// # use switchbot_api::CommandRequest;
123/// let command = CommandRequest {
124///     command: "turnOn".into(),
125///     ..Default::default()
126/// };
127/// ```
128///
129/// [SwitchBot API]: https://github.com/OpenWonderLabs/SwitchBotAPI
130/// [send-device-control-commands]: https://github.com/OpenWonderLabs/SwitchBotAPI/blob/main/README.md#send-device-control-commands
131#[derive(Debug, serde::Serialize)]
132pub struct CommandRequest {
133    /// The command.
134    pub command: String,
135    /// The command parameters.
136    /// The default value is `default`.
137    pub parameter: String,
138    /// The command type.
139    /// The default value is `command`.
140    #[serde(rename = "commandType")]
141    pub command_type: String,
142}
143
144impl Default for CommandRequest {
145    fn default() -> Self {
146        Self {
147            command: String::default(),
148            parameter: "default".into(),
149            command_type: "command".into(),
150        }
151    }
152}
153
154/// Error from the [SwitchBot API].
155///
156/// [SwitchBot API]: https://github.com/OpenWonderLabs/SwitchBotAPI
157#[derive(Debug, thiserror::Error, serde::Deserialize)]
158#[error("SwitchBot API error: {message} ({status_code})")]
159pub struct SwitchBotError {
160    #[serde(rename = "statusCode")]
161    status_code: u16,
162    message: String,
163}
164
165impl<T> From<SwitchBotResponse<T>> for SwitchBotError {
166    fn from(response: SwitchBotResponse<T>) -> Self {
167        Self {
168            status_code: response.statusCode,
169            message: response.message,
170        }
171    }
172}