rship-govee 0.1.1

rship executor for controlling Govee smart home devices
Documentation
use anyhow::Result;
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DeviceResponse {
    pub code: i32,
    pub message: String,
    pub data: Option<Vec<Device>>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Device {
    pub sku: String,
    pub device: String,
    #[serde(rename = "deviceName")]
    pub device_name: String,
    pub capabilities: Vec<Capability>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Capability {
    #[serde(rename = "type")]
    pub capability_type: String,
    pub instance: String,
    pub parameters: Option<serde_json::Value>,
}

#[derive(Clone)]
pub struct GoveeClient {
    client: reqwest::Client,
    api_key: String,
    base_url: String,
    devices: Arc<RwLock<Vec<Device>>>,
}

impl GoveeClient {
    pub fn new(api_key: String) -> Self {
        let client = reqwest::Client::new();
        Self {
            client,
            api_key,
            base_url: "https://openapi.api.govee.com".to_string(),
            devices: Arc::new(RwLock::new(Vec::new())),
        }
    }

    fn build_headers(&self) -> HeaderMap {
        let mut headers = HeaderMap::new();
        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
        headers.insert(
            "Govee-API-Key",
            HeaderValue::from_str(&self.api_key).expect("Invalid API key format"),
        );
        headers
    }

    pub async fn fetch_devices(&self) -> Result<Vec<Device>> {
        let url = format!("{}/router/api/v1/user/devices", self.base_url);

        log::debug!("Fetching devices from: {}", url);

        let response = self
            .client
            .get(&url)
            .headers(self.build_headers())
            .send()
            .await?;

        let status = response.status();
        if !status.is_success() {
            anyhow::bail!("Failed to fetch devices: {}", status);
        }

        let body = response.text().await?;
        let device_response: DeviceResponse = serde_json::from_str(&body)?;

        if let Some(devices) = device_response.data {
            let mut device_cache = self.devices.write().await;
            *device_cache = devices.clone();
            log::info!("Found {} devices", devices.len());
            Ok(devices)
        } else {
            Ok(Vec::new())
        }
    }

    pub async fn get_cached_devices(&self) -> Vec<Device> {
        self.devices.read().await.clone()
    }

    pub async fn control_device(
        &self,
        device_id: &str,
        sku: &str,
        capability: &str,
        instance: &str,
        value: serde_json::Value,
    ) -> Result<serde_json::Value> {
        let url = format!("{}/router/api/v1/device/control", self.base_url);

        let request_body = serde_json::json!({
            "requestId": uuid::Uuid::new_v4().to_string(),
            "payload": {
                "sku": sku,
                "device": device_id,
                "capability": {
                    "type": capability,
                    "instance": instance,
                    "value": value
                }
            }
        });

        log::debug!(
            "Controlling device: {} with {}/{} = {:?}",
            device_id,
            capability,
            instance,
            value
        );

        let response = self
            .client
            .post(&url)
            .headers(self.build_headers())
            .json(&request_body)
            .send()
            .await?;

        let status = response.status();
        let body = response.text().await?;

        if !status.is_success() {
            anyhow::bail!("Control failed: {} - {}", status, body);
        }

        let result: serde_json::Value = serde_json::from_str(&body)?;

        // Check if the API returned success but might have silently failed
        match result.get("code").and_then(|v| v.as_i64()) {
            Some(200) | None => {} // Success or no code field
            Some(code) => {
                log::error!("Govee API returned code {}: {}", code, body);
                anyhow::bail!("Govee API error code {}", code);
            }
        }

        // Log if we suspect a silent failure (e.g., out of range values)
        let is_brightness_range =
            capability == "devices.capabilities.range" && instance == "brightness";
        if is_brightness_range {
            match value.as_u64() {
                Some(val) if !(1..=100).contains(&val) => {
                    log::warn!(
                        "API accepted out-of-range brightness value {} without error",
                        val
                    );
                }
                _ => {}
            }
        }

        Ok(result)
    }

    pub async fn set_power(&self, device_id: &str, sku: &str, on: bool) -> Result<()> {
        self.control_device(
            device_id,
            sku,
            "devices.capabilities.on_off",
            "powerSwitch",
            serde_json::json!(if on { 1 } else { 0 }),
        )
        .await?;
        Ok(())
    }

    pub async fn set_brightness(&self, device_id: &str, sku: &str, brightness: u32) -> Result<()> {
        // Validate and clamp brightness to valid range (1-100)
        let valid_brightness = if brightness < 1 {
            log::warn!(
                "Brightness {} is below minimum (1), setting to 1",
                brightness
            );
            1
        } else if brightness > 100 {
            log::warn!(
                "Brightness {} is above maximum (100), setting to 100",
                brightness
            );
            100
        } else {
            brightness
        };

        self.control_device(
            device_id,
            sku,
            "devices.capabilities.range",
            "brightness",
            serde_json::json!(valid_brightness),
        )
        .await?;
        Ok(())
    }

    pub async fn set_color_rgb(
        &self,
        device_id: &str,
        sku: &str,
        r: u8,
        g: u8,
        b: u8,
    ) -> Result<()> {
        let rgb_value = ((r as u32) << 16) | ((g as u32) << 8) | (b as u32);
        log::debug!(
            "Setting color RGB: r={}, g={}, b={} -> value={} (0x{:06X})",
            r,
            g,
            b,
            rgb_value,
            rgb_value
        );

        let result = self
            .control_device(
                device_id,
                sku,
                "devices.capabilities.color_setting",
                "colorRgb",
                serde_json::json!(rgb_value),
            )
            .await?;

        log::debug!("Color command result: {:?}", result);
        Ok(())
    }

    pub async fn set_color_temperature(
        &self,
        device_id: &str,
        sku: &str,
        temperature: u32,
    ) -> Result<()> {
        // Validate and clamp temperature to valid range (2200-6500K)
        let valid_temperature = if temperature < 2200 {
            log::warn!(
                "Color temperature {}K is below minimum (2200K), setting to 2200K",
                temperature
            );
            2200
        } else if temperature > 6500 {
            log::warn!(
                "Color temperature {}K is above maximum (6500K), setting to 6500K",
                temperature
            );
            6500
        } else {
            temperature
        };

        self.control_device(
            device_id,
            sku,
            "devices.capabilities.color_setting",
            "colorTemperatureK",
            serde_json::json!(valid_temperature),
        )
        .await?;
        Ok(())
    }
}