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