switchbot_api/
switch_bot_service.rs

1use base64::{Engine as _, engine::general_purpose::STANDARD};
2use hmac::{Hmac, Mac};
3use sha2::Sha256;
4use std::{
5    sync::Arc,
6    time::{Instant, SystemTime},
7};
8use uuid::Uuid;
9
10use super::*;
11
12#[derive(Debug, Default)]
13pub(crate) struct SwitchBotService {
14    client: reqwest::Client,
15    token: String,
16    secret: String,
17}
18
19impl SwitchBotService {
20    const HOST: &str = "https://api.switch-bot.com";
21
22    pub fn new(token: &str, secret: &str) -> Arc<Self> {
23        Arc::new(SwitchBotService {
24            client: reqwest::Client::new(),
25            token: token.to_string(),
26            secret: secret.to_string(),
27        })
28    }
29
30    pub async fn load_devices(self: &Arc<SwitchBotService>) -> anyhow::Result<DeviceList> {
31        let url = format!("{}/v1.1/devices", Self::HOST);
32        let request = self.client.get(url);
33        let device_list = self.send_as::<DeviceListResponse>(request).await?;
34
35        let mut devices = DeviceList::with_capacity(
36            device_list.device_list.len() + device_list.infrared_remote_list.len(),
37        );
38        devices.extend(device_list.device_list);
39        devices.extend(device_list.infrared_remote_list);
40        for device in devices.iter_mut() {
41            device.set_service(self);
42        }
43        Ok(devices)
44    }
45
46    pub(crate) async fn command(
47        &self,
48        device_id: &str,
49        command: &CommandRequest,
50    ) -> anyhow::Result<()> {
51        let url = format!("{}/v1.1/devices/{device_id}/commands", Self::HOST);
52        let body = serde_json::to_value(command)?;
53        log::debug!("command.request: {body}");
54        let request = self.client.post(url).json(&body);
55        self.send_as_opt(request).await?;
56        Ok(())
57    }
58
59    pub(crate) async fn status(&self, device_id: &str) -> anyhow::Result<Option<Device>> {
60        let url = format!("{}/v1.1/devices/{device_id}/status", Self::HOST);
61        let request = self.client.get(url);
62        let body_json = self.send_as_json(request).await?;
63        if let serde_json::Value::Object(object) = &body_json {
64            // Hub Mini returns `"body":{}`. Make this not an error.
65            if object.is_empty() {
66                return Ok(None);
67            }
68        }
69        let device: Device = serde_json::from_value(body_json)?;
70        Ok(Some(device))
71    }
72
73    async fn send_as<T: serde::de::DeserializeOwned>(
74        &self,
75        request: reqwest::RequestBuilder,
76    ) -> anyhow::Result<T> {
77        let body_json = self.send_as_json(request).await?;
78        let body: T = serde_json::from_value(body_json)?;
79        Ok(body)
80    }
81
82    async fn send_as_json(
83        &self,
84        request: reqwest::RequestBuilder,
85    ) -> anyhow::Result<serde_json::Value> {
86        let body_json = self
87            .send_as_opt(request)
88            .await?
89            .ok_or_else(|| anyhow::anyhow!("Missing `body`"))?;
90        Ok(body_json)
91    }
92
93    async fn send_as_opt(
94        &self,
95        request: reqwest::RequestBuilder,
96    ) -> anyhow::Result<Option<serde_json::Value>> {
97        let start_time = Instant::now();
98        let response = self.add_headers(request)?.send().await?;
99        log::trace!("response: {response:?}");
100        response.error_for_status_ref()?;
101
102        let json: serde_json::Value = response.json().await?;
103        log::trace!("response.json: {json}: elapsed {:?}", start_time.elapsed());
104        Self::body_from_json(json)
105    }
106
107    fn body_from_json(json: serde_json::Value) -> anyhow::Result<Option<serde_json::Value>> {
108        // First, parse to `Option<serde_json::Value>` because the `body` may be
109        // missing, or doesn't contain required fields.
110        // The `SwitchBotError` should be raised even when the `body` failed to
111        // deserialize.
112        let response: SwitchBotResponse<Option<serde_json::Value>> = serde_json::from_value(json)?;
113
114        // All statusCode other than 100 looks like errors.
115        // https://github.com/OpenWonderLabs/SwitchBotAPI#errors
116        if response.status_code != 100 {
117            return Err(SwitchBotError::from(response).into());
118        }
119        Ok(response.body)
120    }
121
122    fn add_headers(
123        &self,
124        builder: reqwest::RequestBuilder,
125    ) -> anyhow::Result<reqwest::RequestBuilder> {
126        let duration_since_epoch = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?;
127        let t = duration_since_epoch.as_millis().to_string();
128        let nonce = Uuid::new_v4().to_string();
129
130        let mut mac = Hmac::<Sha256>::new_from_slice(self.secret.as_bytes())?;
131        mac.update(self.token.as_bytes());
132        mac.update(t.as_bytes());
133        mac.update(nonce.as_bytes());
134        let result = mac.finalize();
135        let sign = STANDARD.encode(result.into_bytes());
136
137        Ok(builder
138            .header("Authorization", self.token.clone())
139            .header("t", t)
140            .header("sign", sign)
141            .header("nonce", nonce))
142    }
143}
144
145#[derive(Debug, serde::Deserialize)]
146#[serde(rename_all = "camelCase")]
147struct SwitchBotResponse<T> {
148    #[allow(dead_code)]
149    pub status_code: u16,
150    #[allow(dead_code)]
151    pub message: String,
152    pub body: T,
153}
154
155#[derive(Debug, serde::Deserialize)]
156#[serde(rename_all = "camelCase")]
157struct DeviceListResponse {
158    device_list: Vec<Device>,
159    infrared_remote_list: Vec<Device>,
160}
161
162/// Error from the [SwitchBot API].
163///
164/// [SwitchBot API]: https://github.com/OpenWonderLabs/SwitchBotAPI
165#[derive(Debug, thiserror::Error, serde::Deserialize)]
166#[error("SwitchBot API error: {message} ({status_code})")]
167#[serde(rename_all = "camelCase")]
168pub struct SwitchBotError {
169    status_code: u16,
170    message: String,
171}
172
173impl<T> From<SwitchBotResponse<T>> for SwitchBotError {
174    fn from(response: SwitchBotResponse<T>) -> Self {
175        Self {
176            status_code: response.status_code,
177            message: response.message,
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn body_from_json() {
188        let result = SwitchBotService::body_from_json(
189            serde_json::json!({"message":"OK", "statusCode":100, "body":{}}),
190        );
191        assert!(result.is_ok());
192    }
193
194    #[test]
195    fn body_from_json_error() {
196        let result = SwitchBotService::body_from_json(
197            serde_json::json!({"message":"error", "statusCode":500, "body":{}}),
198        );
199        assert!(result.is_err());
200        let error = result.unwrap_err();
201        let switch_bot_error = error.downcast_ref::<SwitchBotError>();
202        assert!(switch_bot_error.is_some());
203        assert_eq!(switch_bot_error.unwrap().status_code, 500);
204    }
205
206    #[test]
207    fn body_from_json_no_body() {
208        let result =
209            SwitchBotService::body_from_json(serde_json::json!({"message":"OK", "statusCode":100}));
210        assert!(result.is_ok());
211        let body = result.unwrap();
212        assert!(body.is_none());
213    }
214
215    #[test]
216    fn error_from_json() -> anyhow::Result<()> {
217        let json_no_body = serde_json::json!(
218            {"message":"unknown command", "statusCode":160});
219        let error: SwitchBotError = serde_json::from_value(json_no_body)?;
220        assert_eq!(error.status_code, 160);
221        assert_eq!(error.message, "unknown command");
222
223        // Some responses have empty `body`. Ensure it's ignored.
224        let json_with_body = serde_json::json!(
225            {"message":"unknown command", "statusCode":160, "body":{}});
226        let error: SwitchBotError = serde_json::from_value(json_with_body)?;
227        assert_eq!(error.status_code, 160);
228        assert_eq!(error.message, "unknown command");
229        Ok(())
230    }
231}