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
9const RETRY_BASE_DELAY: Duration = Duration::from_millis(100);
11
12#[derive(Debug, Clone)]
14pub struct ClientConfig {
15 pub timeout: Duration,
17
18 pub retry_attempts: u32,
22
23 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#[derive(Clone)]
40pub struct GoveeClient {
41 api_key: String,
42 client: reqwest::Client,
43 config: ClientConfig,
44}
45
46impl GoveeClient {
47 pub fn new(api_key: impl Into<String>) -> Self {
50 Self::with_config(api_key, ClientConfig::default())
51 }
52
53 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 last_error = Some(Error::Server {
331 status: status.as_u16(),
332 });
333 continue;
334 }
335
336 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 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
376fn 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 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}