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::<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_opt(request).await?;
56        Ok(())
57    }
58
59    pub(crate) async fn status(&self, device_id: &str) -> anyhow::Result<Device> {
60        let url = format!("{}/v1.1/devices/{device_id}/status", Self::HOST);
61        let request = self.client.get(url);
62        let device = self.send::<Device>(request).await?;
63        Ok(device)
64    }
65
66    async fn send<T: serde::de::DeserializeOwned>(
67        &self,
68        request: reqwest::RequestBuilder,
69    ) -> anyhow::Result<T> {
70        let body_json = self
71            .send_opt(request)
72            .await?
73            .ok_or_else(|| anyhow::anyhow!("Missing `body`"))?;
74        let body: T = serde_json::from_value(body_json)?;
75        Ok(body)
76    }
77
78    async fn send_opt(
79        &self,
80        request: reqwest::RequestBuilder,
81    ) -> anyhow::Result<Option<serde_json::Value>> {
82        let start_time = Instant::now();
83        let response = self.add_headers(request)?.send().await?;
84        log::trace!("response: {response:?}");
85        response.error_for_status_ref()?;
86
87        let json: serde_json::Value = response.json().await?;
88        log::trace!("response.json: {json}: elapsed {:?}", start_time.elapsed());
89        Self::body_from_json(json)
90    }
91
92    fn body_from_json(json: serde_json::Value) -> anyhow::Result<Option<serde_json::Value>> {
93        // First, parse to `Option<serde_json::Value>` because the `body` may be
94        // missing, or doesn't contain required fields.
95        // The `SwitchBotError` should be raised even when the `body` failed to
96        // deserialize.
97        let response: SwitchBotResponse<Option<serde_json::Value>> = serde_json::from_value(json)?;
98
99        // All statusCode other than 100 looks like errors.
100        // https://github.com/OpenWonderLabs/SwitchBotAPI#errors
101        if response.status_code != 100 {
102            return Err(SwitchBotError::from(response).into());
103        }
104        Ok(response.body)
105    }
106
107    fn add_headers(
108        &self,
109        builder: reqwest::RequestBuilder,
110    ) -> anyhow::Result<reqwest::RequestBuilder> {
111        let duration_since_epoch = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?;
112        let t = duration_since_epoch.as_millis().to_string();
113        let nonce = Uuid::new_v4().to_string();
114
115        let mut mac = Hmac::<Sha256>::new_from_slice(self.secret.as_bytes())?;
116        mac.update(self.token.as_bytes());
117        mac.update(t.as_bytes());
118        mac.update(nonce.as_bytes());
119        let result = mac.finalize();
120        let sign = STANDARD.encode(result.into_bytes());
121
122        Ok(builder
123            .header("Authorization", self.token.clone())
124            .header("t", t)
125            .header("sign", sign)
126            .header("nonce", nonce))
127    }
128}
129
130#[derive(Debug, serde::Deserialize)]
131#[serde(rename_all = "camelCase")]
132struct SwitchBotResponse<T> {
133    #[allow(dead_code)]
134    pub status_code: u16,
135    #[allow(dead_code)]
136    pub message: String,
137    pub body: T,
138}
139
140#[derive(Debug, serde::Deserialize)]
141#[serde(rename_all = "camelCase")]
142struct DeviceListResponse {
143    device_list: Vec<Device>,
144    infrared_remote_list: Vec<Device>,
145}
146
147/// Error from the [SwitchBot API].
148///
149/// [SwitchBot API]: https://github.com/OpenWonderLabs/SwitchBotAPI
150#[derive(Debug, thiserror::Error, serde::Deserialize)]
151#[error("SwitchBot API error: {message} ({status_code})")]
152#[serde(rename_all = "camelCase")]
153pub struct SwitchBotError {
154    status_code: u16,
155    message: String,
156}
157
158impl<T> From<SwitchBotResponse<T>> for SwitchBotError {
159    fn from(response: SwitchBotResponse<T>) -> Self {
160        Self {
161            status_code: response.status_code,
162            message: response.message,
163        }
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn body_from_json() {
173        let result = SwitchBotService::body_from_json(
174            serde_json::json!({"message":"OK", "statusCode":100, "body":{}}),
175        );
176        assert!(result.is_ok());
177    }
178
179    #[test]
180    fn body_from_json_error() {
181        let result = SwitchBotService::body_from_json(
182            serde_json::json!({"message":"error", "statusCode":500, "body":{}}),
183        );
184        assert!(result.is_err());
185        let error = result.unwrap_err();
186        let switch_bot_error = error.downcast_ref::<SwitchBotError>();
187        assert!(switch_bot_error.is_some());
188        assert_eq!(switch_bot_error.unwrap().status_code, 500);
189    }
190
191    #[test]
192    fn body_from_json_no_body() {
193        let result =
194            SwitchBotService::body_from_json(serde_json::json!({"message":"OK", "statusCode":100}));
195        assert!(result.is_ok());
196        let body = result.unwrap();
197        assert!(body.is_none());
198    }
199
200    #[test]
201    fn error_from_json() -> anyhow::Result<()> {
202        let json_no_body = serde_json::json!(
203            {"message":"unknown command", "statusCode":160});
204        let error: SwitchBotError = serde_json::from_value(json_no_body)?;
205        assert_eq!(error.status_code, 160);
206        assert_eq!(error.message, "unknown command");
207
208        // Some responses have empty `body`. Ensure it's ignored.
209        let json_with_body = serde_json::json!(
210            {"message":"unknown command", "statusCode":160, "body":{}});
211        let error: SwitchBotError = serde_json::from_value(json_with_body)?;
212        assert_eq!(error.status_code, 160);
213        assert_eq!(error.message, "unknown command");
214        Ok(())
215    }
216}