Skip to main content

govee_api2/
client.rs

1use crate::error::{Error, Result};
2use crate::types::*;
3use serde::de::DeserializeOwned;
4use serde_json::json;
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7const API_BASE: &str = "https://openapi.api.govee.com";
8
9/// Base delay for exponential retry backoff (doubles per attempt).
10const RETRY_BASE_DELAY: Duration = Duration::from_millis(100);
11
12/// Configuration for a [`GoveeClient`].
13#[derive(Debug, Clone)]
14pub struct ClientConfig {
15    /// Per-request timeout (default: 10 seconds)
16    pub timeout: Duration,
17
18    /// Number of retries after a failed attempt, for transport errors and
19    /// HTTP 5xx responses (default: 3). Rate-limit (429) responses are
20    /// never retried.
21    pub retry_attempts: u32,
22
23    /// API base URL. Override for testing against a mock server
24    /// (default: `https://openapi.api.govee.com`).
25    pub base_url: String,
26}
27
28impl Default for ClientConfig {
29    fn default() -> Self {
30        Self {
31            timeout: Duration::from_secs(10),
32            retry_attempts: 3,
33            base_url: API_BASE.to_string(),
34        }
35    }
36}
37
38/// Govee API client for the v2 router-based endpoints
39#[derive(Clone)]
40pub struct GoveeClient {
41    api_key: String,
42    client: reqwest::Client,
43    config: ClientConfig,
44}
45
46impl GoveeClient {
47    /// Create a new Govee API client with default configuration
48    /// (10 second timeout, 3 retries).
49    pub fn new(api_key: impl Into<String>) -> Self {
50        Self::with_config(api_key, ClientConfig::default())
51    }
52
53    /// Create a new Govee API client with a custom configuration.
54    pub fn with_config(api_key: impl Into<String>, config: ClientConfig) -> Self {
55        let client = reqwest::Client::builder()
56            .timeout(config.timeout)
57            .build()
58            .expect("failed to build reqwest client");
59
60        Self {
61            api_key: api_key.into(),
62            client,
63            config,
64        }
65    }
66
67    /// List all devices and groups
68    pub async fn get_devices(&self) -> Result<Vec<Device>> {
69        let response = self.request("/router/api/v1/user/devices", None).await?;
70        let api_response: ApiResponse<Vec<Device>> = Self::parse_json(response).await?;
71
72        Self::check_api_code(api_response.code, &api_response.message)?;
73
74        Ok(api_response.data)
75    }
76
77    /// Get the current state of a device
78    pub async fn get_device_state(&self, device_id: &str, sku: &str) -> Result<DeviceState> {
79        let payload = json!({
80            "requestId": generate_request_id(),
81            "payload": {
82                "sku": sku,
83                "device": device_id
84            }
85        });
86
87        let response = self
88            .request("/router/api/v1/device/state", Some(payload))
89            .await?;
90        let api_response: DeviceStateResponse = Self::parse_json(response).await?;
91
92        Self::check_api_code(api_response.code, &api_response.msg)?;
93
94        Ok(DeviceState::from_capabilities(
95            api_response.payload.capabilities,
96        ))
97    }
98
99    /// List the dynamic light scenes available for a device
100    /// (`POST /router/api/v1/device/scenes`).
101    pub async fn get_dynamic_scenes(&self, device_id: &str, sku: &str) -> Result<Vec<Scene>> {
102        self.get_scene_list("/router/api/v1/device/scenes", device_id, sku)
103            .await
104    }
105
106    /// List the user-created DIY scenes available for a device
107    /// (`POST /router/api/v1/device/diy-scenes`).
108    pub async fn get_diy_scenes(&self, device_id: &str, sku: &str) -> Result<Vec<Scene>> {
109        self.get_scene_list("/router/api/v1/device/diy-scenes", device_id, sku)
110            .await
111    }
112
113    async fn get_scene_list(&self, path: &str, device_id: &str, sku: &str) -> Result<Vec<Scene>> {
114        let payload = json!({
115            "requestId": generate_request_id(),
116            "payload": {
117                "sku": sku,
118                "device": device_id
119            }
120        });
121
122        let response = self.request(path, Some(payload)).await?;
123        let api_response: ScenesResponse = Self::parse_json(response).await?;
124
125        Self::check_api_code(api_response.code, &api_response.msg)?;
126
127        Ok(Scene::from_capabilities(&api_response.payload.capabilities))
128    }
129
130    /// Activate a scene previously returned by [`Self::get_dynamic_scenes`]
131    /// or [`Self::get_diy_scenes`].
132    pub async fn set_scene(&self, device_id: &str, sku: &str, scene: &Scene) -> Result<()> {
133        self.send_control(
134            device_id,
135            sku,
136            &scene.capability_type,
137            &scene.instance,
138            scene.control_value(),
139        )
140        .await
141    }
142
143    /// Turn a device on
144    pub async fn turn_on(&self, device_id: &str, sku: &str) -> Result<()> {
145        self.control_power(device_id, sku, PowerState::On).await
146    }
147
148    /// Turn a device off
149    pub async fn turn_off(&self, device_id: &str, sku: &str) -> Result<()> {
150        self.control_power(device_id, sku, PowerState::Off).await
151    }
152
153    /// Toggle device power
154    async fn control_power(&self, device_id: &str, sku: &str, state: PowerState) -> Result<()> {
155        let value = if state.is_on() { 1 } else { 0 };
156
157        self.send_control(
158            device_id,
159            sku,
160            "devices.capabilities.on_off",
161            "powerSwitch",
162            json!(value),
163        )
164        .await
165    }
166
167    /// Set device brightness (0-100)
168    pub async fn set_brightness(&self, device_id: &str, sku: &str, brightness: u8) -> Result<()> {
169        let brightness = brightness.min(100);
170        self.send_control(
171            device_id,
172            sku,
173            "devices.capabilities.range",
174            "brightness",
175            json!(brightness),
176        )
177        .await
178    }
179
180    /// Set device color
181    pub async fn set_color(&self, device_id: &str, sku: &str, color: Color) -> Result<()> {
182        self.send_control(
183            device_id,
184            sku,
185            "devices.capabilities.color_setting",
186            "colorRgb",
187            json!(color.to_packed()),
188        )
189        .await
190    }
191
192    /// Set color temperature in Kelvin (2000-9000)
193    pub async fn set_color_temperature(
194        &self,
195        device_id: &str,
196        sku: &str,
197        kelvin: i32,
198    ) -> Result<()> {
199        let kelvin = kelvin.clamp(2000, 9000);
200        self.send_control(
201            device_id,
202            sku,
203            "devices.capabilities.color_setting",
204            "colorTemperatureK",
205            json!(kelvin),
206        )
207        .await
208    }
209
210    /// Set the color of specific segments of a segmented light.
211    ///
212    /// `segments` are zero-based segment indices as advertised by the
213    /// device's `segmentedColorRgb` capability.
214    pub async fn set_segment_color(
215        &self,
216        device_id: &str,
217        sku: &str,
218        segments: &[u8],
219        r: u8,
220        g: u8,
221        b: u8,
222    ) -> Result<()> {
223        let value = json!({
224            "segment": segments,
225            "rgb": Color::new(r, g, b).to_packed(),
226        });
227
228        self.send_control(
229            device_id,
230            sku,
231            "devices.capabilities.segment_color_setting",
232            "segmentedColorRgb",
233            value,
234        )
235        .await
236    }
237
238    /// Set the brightness (0-100) of specific segments of a segmented light.
239    pub async fn set_segment_brightness(
240        &self,
241        device_id: &str,
242        sku: &str,
243        segments: &[u8],
244        brightness: u8,
245    ) -> Result<()> {
246        let value = json!({
247            "segment": segments,
248            "brightness": brightness.min(100),
249        });
250
251        self.send_control(
252            device_id,
253            sku,
254            "devices.capabilities.segment_color_setting",
255            "segmentedBrightness",
256            value,
257        )
258        .await
259    }
260
261    /// Send a control command to a device
262    async fn send_control(
263        &self,
264        device_id: &str,
265        sku: &str,
266        capability_type: &str,
267        instance: &str,
268        value: serde_json::Value,
269    ) -> Result<()> {
270        let payload = ControlRequest {
271            request_id: generate_request_id(),
272            payload: ControlPayload {
273                sku: sku.to_string(),
274                device: device_id.to_string(),
275                capability: CapabilityCommand {
276                    capability_type: capability_type.to_string(),
277                    instance: instance.to_string(),
278                    value,
279                },
280            },
281        };
282        let payload = serde_json::to_value(&payload)?;
283
284        let response = self
285            .request("/router/api/v1/device/control", Some(payload))
286            .await?;
287        let control_response: ControlResponse = Self::parse_json(response).await?;
288
289        Self::check_api_code(control_response.code, &control_response.msg)?;
290
291        Ok(())
292    }
293
294    /// Perform a request with retry/backoff. `body: None` sends a GET,
295    /// `Some(json)` a POST.
296    ///
297    /// Transport errors and HTTP 5xx responses are retried up to
298    /// `retry_attempts` times with exponential backoff; 429 responses are
299    /// surfaced immediately as [`Error::RateLimited`].
300    async fn request(
301        &self,
302        path: &str,
303        body: Option<serde_json::Value>,
304    ) -> Result<reqwest::Response> {
305        let url = format!("{}{}", self.config.base_url, path);
306        let mut last_error: Option<Error> = None;
307
308        for attempt in 0..=self.config.retry_attempts {
309            if attempt > 0 {
310                tokio::time::sleep(RETRY_BASE_DELAY * 2u32.pow(attempt - 1)).await;
311            }
312
313            let request = match &body {
314                Some(body) => self.client.post(&url).json(body),
315                None => self.client.get(&url),
316            }
317            .header("Govee-API-Key", &self.api_key)
318            .header("Content-Type", "application/json");
319
320            match request.send().await {
321                Ok(response) => {
322                    let status = response.status();
323
324                    if status.is_success() {
325                        return Ok(response);
326                    }
327
328                    if status.is_server_error() {
329                        // Retryable
330                        last_error = Some(Error::Server {
331                            status: status.as_u16(),
332                        });
333                        continue;
334                    }
335
336                    // Non-retryable client errors
337                    return Err(match status.as_u16() {
338                        401 | 403 => Error::InvalidApiKey,
339                        429 => Error::RateLimited {
340                            retry_after_secs: parse_retry_after(response.headers()),
341                        },
342                        _ => {
343                            let body = response.text().await.unwrap_or_default();
344                            Error::InvalidResponse(format!("HTTP {}: {}", status, body))
345                        }
346                    });
347                }
348                Err(err) => {
349                    // Transport error (connection failure, timeout, ...):
350                    // retryable
351                    last_error = Some(Error::Request(err));
352                }
353            }
354        }
355
356        Err(last_error.unwrap_or_else(|| {
357            Error::InvalidResponse("request failed without a recorded error".to_string())
358        }))
359    }
360
361    async fn parse_json<T: DeserializeOwned>(response: reqwest::Response) -> Result<T> {
362        Ok(response.json().await?)
363    }
364
365    fn check_api_code(code: i32, message: &str) -> Result<()> {
366        if code != 0 && code != 200 {
367            return Err(Error::Api {
368                code,
369                message: message.to_string(),
370            });
371        }
372        Ok(())
373    }
374}
375
376/// Extract a retry delay in seconds from rate-limit response headers.
377///
378/// Govee does not document specific headers for the platform API, so this
379/// checks the standard `Retry-After` plus the `X-RateLimit-Reset` /
380/// `API-RateLimit-Reset` variants used by the older Govee developer API
381/// (which carry a UTC epoch timestamp).
382fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<u64> {
383    if let Some(secs) = header_u64(headers, "Retry-After") {
384        return Some(secs);
385    }
386
387    for name in ["X-RateLimit-Reset", "API-RateLimit-Reset"] {
388        if let Some(value) = header_u64(headers, name) {
389            // Values this large are epoch timestamps; convert to a delta.
390            return Some(if value > 1_000_000_000 {
391                let now = SystemTime::now()
392                    .duration_since(UNIX_EPOCH)
393                    .unwrap_or_default()
394                    .as_secs();
395                value.saturating_sub(now)
396            } else {
397                value
398            });
399        }
400    }
401
402    None
403}
404
405fn header_u64(headers: &reqwest::header::HeaderMap, name: &str) -> Option<u64> {
406    headers.get(name)?.to_str().ok()?.trim().parse().ok()
407}
408
409fn generate_request_id() -> String {
410    let timestamp = SystemTime::now()
411        .duration_since(UNIX_EPOCH)
412        .unwrap()
413        .as_nanos();
414    format!("rust-{}", timestamp)
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn test_client_creation() {
423        let client = GoveeClient::new("test-key");
424        assert_eq!(client.api_key, "test-key");
425        assert_eq!(client.config.timeout, Duration::from_secs(10));
426        assert_eq!(client.config.retry_attempts, 3);
427        assert_eq!(client.config.base_url, API_BASE);
428    }
429
430    #[test]
431    fn test_client_with_config() {
432        let client = GoveeClient::with_config(
433            "test-key",
434            ClientConfig {
435                timeout: Duration::from_millis(250),
436                retry_attempts: 0,
437                base_url: "http://localhost:1234".to_string(),
438            },
439        );
440        assert_eq!(client.config.timeout, Duration::from_millis(250));
441        assert_eq!(client.config.retry_attempts, 0);
442        assert_eq!(client.config.base_url, "http://localhost:1234");
443    }
444
445    #[test]
446    fn test_request_id_generation() {
447        let id1 = generate_request_id();
448        let id2 = generate_request_id();
449        assert!(id1.starts_with("rust-"));
450        assert_ne!(id1, id2);
451    }
452
453    #[test]
454    fn test_parse_retry_after_seconds() {
455        let mut headers = reqwest::header::HeaderMap::new();
456        headers.insert("Retry-After", "30".parse().unwrap());
457        assert_eq!(parse_retry_after(&headers), Some(30));
458    }
459
460    #[test]
461    fn test_parse_retry_after_epoch_reset() {
462        let mut headers = reqwest::header::HeaderMap::new();
463        let reset = SystemTime::now()
464            .duration_since(UNIX_EPOCH)
465            .unwrap()
466            .as_secs()
467            + 60;
468        headers.insert("X-RateLimit-Reset", reset.to_string().parse().unwrap());
469        let secs = parse_retry_after(&headers).unwrap();
470        assert!((59..=61).contains(&secs), "expected ~60s, got {secs}");
471    }
472
473    #[test]
474    fn test_parse_retry_after_missing() {
475        let headers = reqwest::header::HeaderMap::new();
476        assert_eq!(parse_retry_after(&headers), None);
477    }
478}