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(crate) 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            .send()
33            .await?
34            .json()
35            .await?;
36        log::trace!("devices.json: {json:#?}");
37        let response: SwitchBotResponse<DeviceListResponse> = serde_json::from_value(json)?;
38        // log::trace!("devices: {response:#?}");
39        let mut devices = DeviceList::with_capacity(
40            response.body.device_list.len() + response.body.infrared_remote_list.len(),
41        );
42        devices.extend(response.body.device_list);
43        devices.extend(response.body.infrared_remote_list);
44        for device in devices.iter_mut() {
45            device.set_service(self);
46        }
47        Ok(devices)
48    }
49
50    pub(crate) async fn command(
51        &self,
52        device_id: &str,
53        command: &CommandRequest,
54    ) -> anyhow::Result<()> {
55        let url = format!("{}/v1.1/devices/{device_id}/commands", Self::HOST);
56        let body = serde_json::to_value(command)?;
57        log::debug!("command.request: {body}");
58        let json: serde_json::Value = self
59            .add_headers(self.client.post(url))?
60            .json(&body)
61            .send()
62            .await?
63            .json()
64            .await?;
65
66        log::trace!("command.response: {json}");
67        let response: SwitchBotError = serde_json::from_value(json)?;
68        // All statusCode other than 100 looks like errors.
69        // https://github.com/OpenWonderLabs/SwitchBotAPI?tab=readme-ov-file#errors
70        if response.status_code != 100 {
71            return Err(response.into());
72        }
73        Ok(())
74    }
75
76    fn add_headers(
77        &self,
78        builder: reqwest::RequestBuilder,
79    ) -> anyhow::Result<reqwest::RequestBuilder> {
80        let duration_since_epoch = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?;
81        let t = duration_since_epoch.as_millis().to_string();
82        let nonce = Uuid::new_v4().to_string();
83
84        let mut mac = Hmac::<Sha256>::new_from_slice(self.secret.as_bytes())?;
85        mac.update(self.token.as_bytes());
86        mac.update(t.as_bytes());
87        mac.update(nonce.as_bytes());
88        let result = mac.finalize();
89        let sign = STANDARD.encode(result.into_bytes());
90
91        Ok(builder
92            .header("Authorization", self.token.clone())
93            .header("t", t)
94            .header("sign", sign)
95            .header("nonce", nonce))
96    }
97}
98
99#[derive(Debug, serde::Deserialize)]
100#[serde(rename_all = "camelCase")]
101struct SwitchBotResponse<T> {
102    #[allow(dead_code)]
103    pub status_code: u16,
104    #[allow(dead_code)]
105    pub message: String,
106    pub body: T,
107}
108
109#[derive(Debug, serde::Deserialize)]
110#[serde(rename_all = "camelCase")]
111struct DeviceListResponse {
112    device_list: Vec<Device>,
113    infrared_remote_list: Vec<Device>,
114}
115
116/// A command request to send to the [SwitchBot API].
117///
118/// For more details of each field, please refer to the [SwitchBot
119/// documentation about device control commands][send-device-control-commands].
120///
121/// # Examples
122/// ```
123/// # use switchbot_api::CommandRequest;
124/// let command = CommandRequest {
125///     command: "turnOn".into(),
126///     ..Default::default()
127/// };
128/// ```
129///
130/// [SwitchBot API]: https://github.com/OpenWonderLabs/SwitchBotAPI
131/// [send-device-control-commands]: https://github.com/OpenWonderLabs/SwitchBotAPI/blob/main/README.md#send-device-control-commands
132#[derive(Debug, serde::Serialize)]
133#[serde(rename_all = "camelCase")]
134pub struct CommandRequest {
135    /// The command.
136    pub command: String,
137    /// The command parameters.
138    /// The default value is `default`.
139    pub parameter: String,
140    /// The command type.
141    /// The default value is `command`.
142    pub command_type: String,
143}
144
145impl Default for CommandRequest {
146    fn default() -> Self {
147        Self {
148            command: String::default(),
149            parameter: "default".into(),
150            command_type: "command".into(),
151        }
152    }
153}
154
155/// Error from the [SwitchBot API].
156///
157/// [SwitchBot API]: https://github.com/OpenWonderLabs/SwitchBotAPI
158#[derive(Debug, thiserror::Error, serde::Deserialize)]
159#[error("SwitchBot API error: {message} ({status_code})")]
160#[serde(rename_all = "camelCase")]
161pub struct SwitchBotError {
162    status_code: u16,
163    message: String,
164}
165
166impl<T> From<SwitchBotResponse<T>> for SwitchBotError {
167    fn from(response: SwitchBotResponse<T>) -> Self {
168        Self {
169            status_code: response.status_code,
170            message: response.message,
171        }
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn error_from_json() -> anyhow::Result<()> {
181        let json_no_body = serde_json::json!(
182            {"message":"unknown command", "statusCode":160});
183        let error: SwitchBotError = serde_json::from_value(json_no_body)?;
184        assert_eq!(error.status_code, 160);
185        assert_eq!(error.message, "unknown command");
186
187        // Some responses have empty `body`. Ensure it's ignored.
188        let json_with_body = serde_json::json!(
189            {"message":"unknown command", "statusCode":160, "body":{}});
190        let error: SwitchBotError = serde_json::from_value(json_with_body)?;
191        assert_eq!(error.status_code, 160);
192        assert_eq!(error.message, "unknown command");
193        Ok(())
194    }
195}