use crate::error::{Error, Result};
use crate::types::*;
use serde::de::DeserializeOwned;
use serde_json::json;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const API_BASE: &str = "https://openapi.api.govee.com";
const RETRY_BASE_DELAY: Duration = Duration::from_millis(100);
#[derive(Debug, Clone)]
pub struct ClientConfig {
pub timeout: Duration,
pub retry_attempts: u32,
pub base_url: String,
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
timeout: Duration::from_secs(10),
retry_attempts: 3,
base_url: API_BASE.to_string(),
}
}
}
#[derive(Clone)]
pub struct GoveeClient {
api_key: String,
client: reqwest::Client,
config: ClientConfig,
}
impl GoveeClient {
pub fn new(api_key: impl Into<String>) -> Self {
Self::with_config(api_key, ClientConfig::default())
}
pub fn with_config(api_key: impl Into<String>, config: ClientConfig) -> Self {
let client = reqwest::Client::builder()
.timeout(config.timeout)
.build()
.expect("failed to build reqwest client");
Self {
api_key: api_key.into(),
client,
config,
}
}
pub async fn get_devices(&self) -> Result<Vec<Device>> {
let response = self.request("/router/api/v1/user/devices", None).await?;
let api_response: ApiResponse<Vec<Device>> = Self::parse_json(response).await?;
Self::check_api_code(api_response.code, &api_response.message)?;
Ok(api_response.data)
}
pub async fn get_device_state(&self, device_id: &str, sku: &str) -> Result<DeviceState> {
let payload = json!({
"requestId": generate_request_id(),
"payload": {
"sku": sku,
"device": device_id
}
});
let response = self
.request("/router/api/v1/device/state", Some(payload))
.await?;
let api_response: DeviceStateResponse = Self::parse_json(response).await?;
Self::check_api_code(api_response.code, &api_response.msg)?;
Ok(DeviceState::from_capabilities(
api_response.payload.capabilities,
))
}
pub async fn get_dynamic_scenes(&self, device_id: &str, sku: &str) -> Result<Vec<Scene>> {
self.get_scene_list("/router/api/v1/device/scenes", device_id, sku)
.await
}
pub async fn get_diy_scenes(&self, device_id: &str, sku: &str) -> Result<Vec<Scene>> {
self.get_scene_list("/router/api/v1/device/diy-scenes", device_id, sku)
.await
}
async fn get_scene_list(&self, path: &str, device_id: &str, sku: &str) -> Result<Vec<Scene>> {
let payload = json!({
"requestId": generate_request_id(),
"payload": {
"sku": sku,
"device": device_id
}
});
let response = self.request(path, Some(payload)).await?;
let api_response: ScenesResponse = Self::parse_json(response).await?;
Self::check_api_code(api_response.code, &api_response.msg)?;
Ok(Scene::from_capabilities(&api_response.payload.capabilities))
}
pub async fn set_scene(&self, device_id: &str, sku: &str, scene: &Scene) -> Result<()> {
self.send_control(
device_id,
sku,
&scene.capability_type,
&scene.instance,
scene.control_value(),
)
.await
}
pub async fn turn_on(&self, device_id: &str, sku: &str) -> Result<()> {
self.control_power(device_id, sku, PowerState::On).await
}
pub async fn turn_off(&self, device_id: &str, sku: &str) -> Result<()> {
self.control_power(device_id, sku, PowerState::Off).await
}
async fn control_power(&self, device_id: &str, sku: &str, state: PowerState) -> Result<()> {
let value = if state.is_on() { 1 } else { 0 };
self.send_control(
device_id,
sku,
"devices.capabilities.on_off",
"powerSwitch",
json!(value),
)
.await
}
pub async fn set_brightness(&self, device_id: &str, sku: &str, brightness: u8) -> Result<()> {
let brightness = brightness.min(100);
self.send_control(
device_id,
sku,
"devices.capabilities.range",
"brightness",
json!(brightness),
)
.await
}
pub async fn set_color(&self, device_id: &str, sku: &str, color: Color) -> Result<()> {
self.send_control(
device_id,
sku,
"devices.capabilities.color_setting",
"colorRgb",
json!(color.to_packed()),
)
.await
}
pub async fn set_color_temperature(
&self,
device_id: &str,
sku: &str,
kelvin: i32,
) -> Result<()> {
let kelvin = kelvin.clamp(2000, 9000);
self.send_control(
device_id,
sku,
"devices.capabilities.color_setting",
"colorTemperatureK",
json!(kelvin),
)
.await
}
pub async fn set_segment_color(
&self,
device_id: &str,
sku: &str,
segments: &[u8],
r: u8,
g: u8,
b: u8,
) -> Result<()> {
let value = json!({
"segment": segments,
"rgb": Color::new(r, g, b).to_packed(),
});
self.send_control(
device_id,
sku,
"devices.capabilities.segment_color_setting",
"segmentedColorRgb",
value,
)
.await
}
pub async fn set_segment_brightness(
&self,
device_id: &str,
sku: &str,
segments: &[u8],
brightness: u8,
) -> Result<()> {
let value = json!({
"segment": segments,
"brightness": brightness.min(100),
});
self.send_control(
device_id,
sku,
"devices.capabilities.segment_color_setting",
"segmentedBrightness",
value,
)
.await
}
async fn send_control(
&self,
device_id: &str,
sku: &str,
capability_type: &str,
instance: &str,
value: serde_json::Value,
) -> Result<()> {
let payload = ControlRequest {
request_id: generate_request_id(),
payload: ControlPayload {
sku: sku.to_string(),
device: device_id.to_string(),
capability: CapabilityCommand {
capability_type: capability_type.to_string(),
instance: instance.to_string(),
value,
},
},
};
let payload = serde_json::to_value(&payload)?;
let response = self
.request("/router/api/v1/device/control", Some(payload))
.await?;
let control_response: ControlResponse = Self::parse_json(response).await?;
Self::check_api_code(control_response.code, &control_response.msg)?;
Ok(())
}
async fn request(
&self,
path: &str,
body: Option<serde_json::Value>,
) -> Result<reqwest::Response> {
let url = format!("{}{}", self.config.base_url, path);
let mut last_error: Option<Error> = None;
for attempt in 0..=self.config.retry_attempts {
if attempt > 0 {
tokio::time::sleep(RETRY_BASE_DELAY * 2u32.pow(attempt - 1)).await;
}
let request = match &body {
Some(body) => self.client.post(&url).json(body),
None => self.client.get(&url),
}
.header("Govee-API-Key", &self.api_key)
.header("Content-Type", "application/json");
match request.send().await {
Ok(response) => {
let status = response.status();
if status.is_success() {
return Ok(response);
}
if status.is_server_error() {
last_error = Some(Error::Server {
status: status.as_u16(),
});
continue;
}
return Err(match status.as_u16() {
401 | 403 => Error::InvalidApiKey,
429 => Error::RateLimited {
retry_after_secs: parse_retry_after(response.headers()),
},
_ => {
let body = response.text().await.unwrap_or_default();
Error::InvalidResponse(format!("HTTP {}: {}", status, body))
}
});
}
Err(err) => {
last_error = Some(Error::Request(err));
}
}
}
Err(last_error.unwrap_or_else(|| {
Error::InvalidResponse("request failed without a recorded error".to_string())
}))
}
async fn parse_json<T: DeserializeOwned>(response: reqwest::Response) -> Result<T> {
Ok(response.json().await?)
}
fn check_api_code(code: i32, message: &str) -> Result<()> {
if code != 0 && code != 200 {
return Err(Error::Api {
code,
message: message.to_string(),
});
}
Ok(())
}
}
fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<u64> {
if let Some(secs) = header_u64(headers, "Retry-After") {
return Some(secs);
}
for name in ["X-RateLimit-Reset", "API-RateLimit-Reset"] {
if let Some(value) = header_u64(headers, name) {
return Some(if value > 1_000_000_000 {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
value.saturating_sub(now)
} else {
value
});
}
}
None
}
fn header_u64(headers: &reqwest::header::HeaderMap, name: &str) -> Option<u64> {
headers.get(name)?.to_str().ok()?.trim().parse().ok()
}
fn generate_request_id() -> String {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
format!("rust-{}", timestamp)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_creation() {
let client = GoveeClient::new("test-key");
assert_eq!(client.api_key, "test-key");
assert_eq!(client.config.timeout, Duration::from_secs(10));
assert_eq!(client.config.retry_attempts, 3);
assert_eq!(client.config.base_url, API_BASE);
}
#[test]
fn test_client_with_config() {
let client = GoveeClient::with_config(
"test-key",
ClientConfig {
timeout: Duration::from_millis(250),
retry_attempts: 0,
base_url: "http://localhost:1234".to_string(),
},
);
assert_eq!(client.config.timeout, Duration::from_millis(250));
assert_eq!(client.config.retry_attempts, 0);
assert_eq!(client.config.base_url, "http://localhost:1234");
}
#[test]
fn test_request_id_generation() {
let id1 = generate_request_id();
let id2 = generate_request_id();
assert!(id1.starts_with("rust-"));
assert_ne!(id1, id2);
}
#[test]
fn test_parse_retry_after_seconds() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert("Retry-After", "30".parse().unwrap());
assert_eq!(parse_retry_after(&headers), Some(30));
}
#[test]
fn test_parse_retry_after_epoch_reset() {
let mut headers = reqwest::header::HeaderMap::new();
let reset = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ 60;
headers.insert("X-RateLimit-Reset", reset.to_string().parse().unwrap());
let secs = parse_retry_after(&headers).unwrap();
assert!((59..=61).contains(&secs), "expected ~60s, got {secs}");
}
#[test]
fn test_parse_retry_after_missing() {
let headers = reqwest::header::HeaderMap::new();
assert_eq!(parse_retry_after(&headers), None);
}
}