use std::collections::HashMap;
use std::fmt;
use std::sync::{Arc, Weak};
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
use crate::backend::GoveeBackend;
use crate::backend::cloud::CloudBackend;
use crate::backend::local::LocalBackend;
use crate::config::{BackendPreference, Config};
use crate::error::{GoveeError, Result};
use crate::scene::{SceneColor, SceneRegistry, SceneTarget};
use crate::types::{BackendType, Color, Device, DeviceId, DeviceState};
const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(2);
struct RegisteredDevice {
device: Device,
active_backend: BackendType,
has_cloud: bool,
has_local: bool,
}
struct CacheEntry {
state: DeviceState,
source: CacheSource,
updated_at: Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CacheSource {
Optimistic,
Confirmed,
Stale,
}
pub struct DeviceRegistry {
devices: HashMap<DeviceId, RegisteredDevice>,
cloud: Option<Arc<dyn GoveeBackend>>,
local: Option<Arc<dyn GoveeBackend>>,
#[allow(dead_code)]
alias_map: HashMap<String, DeviceId>,
#[allow(dead_code)]
name_map: HashMap<String, DeviceId>,
#[allow(dead_code)]
group_map: HashMap<String, Vec<DeviceId>>,
state_cache: RwLock<HashMap<DeviceId, CacheEntry>>,
scene_registry: SceneRegistry,
cancel: CancellationToken,
#[allow(dead_code)]
config: Config,
}
impl DeviceRegistry {
pub async fn start(config: Config) -> Result<Arc<Self>> {
if config.backend() == BackendPreference::CloudOnly && config.api_key().is_none() {
return Err(GoveeError::InvalidConfig(
"CloudOnly backend requires an API key".into(),
));
}
let cloud: Option<Arc<dyn GoveeBackend>> =
if config.backend() != BackendPreference::LocalOnly {
if let Some(key) = config.api_key() {
Some(Arc::new(CloudBackend::new(key.to_string(), None, None)?))
} else {
None
}
} else {
None
};
let local: Option<Arc<dyn GoveeBackend>> = match config.backend() {
BackendPreference::CloudOnly => None,
_ => {
match LocalBackend::new(DISCOVERY_TIMEOUT, config.discovery_interval_secs()).await {
Ok(lb) => Some(Arc::new(lb)),
Err(GoveeError::BackendUnavailable(msg)) => {
tracing::warn!("local backend unavailable: {msg}");
if config.backend() == BackendPreference::LocalOnly {
return Err(GoveeError::BackendUnavailable(msg));
}
None
}
Err(e) => return Err(e),
}
}
};
Self::build(config, cloud, local).await
}
async fn build(
config: Config,
cloud: Option<Arc<dyn GoveeBackend>>,
local: Option<Arc<dyn GoveeBackend>>,
) -> Result<Arc<Self>> {
let cloud_devices = match &cloud {
Some(b) => match b.list_devices().await {
Ok(devs) => devs,
Err(e) if config.backend() == BackendPreference::CloudOnly => return Err(e),
Err(e) => {
tracing::warn!("cloud list_devices failed, proceeding without cloud: {e}");
Vec::new()
}
},
None => Vec::new(),
};
let local_devices = match &local {
Some(b) => match b.list_devices().await {
Ok(devs) => devs,
Err(e) if config.backend() == BackendPreference::LocalOnly => return Err(e),
Err(e) => {
tracing::warn!("local list_devices failed, proceeding without local: {e}");
Vec::new()
}
},
None => Vec::new(),
};
let mut devices = HashMap::new();
for dev in cloud_devices {
devices.insert(
dev.id.clone(),
RegisteredDevice {
device: dev,
active_backend: BackendType::Cloud,
has_cloud: true,
has_local: false,
},
);
}
for dev in local_devices {
match devices.get_mut(&dev.id) {
Some(existing) => {
existing.active_backend = BackendType::Local;
existing.device.backend = BackendType::Local;
existing.has_local = true;
tracing::debug!(
device = %existing.device.id,
"device found in both backends, using local"
);
}
None => {
devices.insert(
dev.id.clone(),
RegisteredDevice {
device: dev,
active_backend: BackendType::Local,
has_cloud: false,
has_local: true,
},
);
}
}
}
match config.backend() {
BackendPreference::CloudOnly => {
for reg in devices.values_mut() {
reg.active_backend = BackendType::Cloud;
reg.device.backend = BackendType::Cloud;
}
tracing::debug!("CloudOnly mode: all devices assigned to cloud backend");
}
BackendPreference::LocalOnly => {
let before = devices.len();
devices.retain(|_id, reg| reg.active_backend == BackendType::Local);
let removed = before - devices.len();
if removed > 0 {
tracing::info!(
removed,
"LocalOnly mode: removed cloud-only device(s) from registry"
);
}
tracing::debug!(
remaining = devices.len(),
"LocalOnly mode: all remaining devices assigned to local backend"
);
}
BackendPreference::Auto => {
for reg in devices.values() {
tracing::debug!(
device = %reg.device.id,
backend = %reg.active_backend,
"Auto mode: device backend assignment"
);
}
}
}
devices.retain(|_id, reg| match reg.active_backend {
BackendType::Cloud => cloud.is_some(),
BackendType::Local => local.is_some(),
});
let mut name_map = HashMap::new();
let mut sorted_devices: Vec<_> = devices.values().collect();
sorted_devices.sort_by(|a, b| a.device.id.as_str().cmp(b.device.id.as_str()));
for reg in sorted_devices {
let key = reg.device.name.to_lowercase();
use std::collections::hash_map::Entry;
match name_map.entry(key) {
Entry::Occupied(mut e) => {
tracing::warn!(
name = %reg.device.name,
new_device = %reg.device.id,
previous_device = %e.get(),
"duplicate device name, overwriting previous mapping"
);
e.insert(reg.device.id.clone());
}
Entry::Vacant(e) => {
e.insert(reg.device.id.clone());
}
}
}
let mut alias_map = HashMap::new();
for (alias, target) in config.aliases() {
let alias_key = alias.to_lowercase();
let target_key = target.to_lowercase();
match name_map.get(&target_key) {
Some(device_id) => {
if let Some(prev) = alias_map.insert(alias_key, device_id.clone()) {
tracing::warn!(
alias = %alias,
new_target = %device_id,
previous_target = %prev,
"case-insensitive alias collision, overwriting"
);
}
}
None => {
tracing::warn!(
alias = %alias,
target = %target,
"alias target does not match any device name"
);
}
}
}
let mut group_map = HashMap::new();
for (group_name, member_names) in config.groups() {
let mut members = Vec::new();
let mut seen = std::collections::HashSet::new();
for member in member_names {
let key = member.to_lowercase();
let resolved = name_map.get(&key).or_else(|| alias_map.get(&key));
match resolved {
Some(id) if devices.contains_key(id) => {
if seen.insert(id.clone()) {
members.push(id.clone());
}
}
Some(_) => {
tracing::warn!(
group = %group_name,
member = %member,
"group member resolved but device was pruned by backend selection, skipping"
);
}
None => {
tracing::warn!(
group = %group_name,
member = %member,
"group member does not match any device or alias, skipping"
);
}
}
}
let group_key = group_name.to_lowercase();
if let Some(prev) = group_map.insert(group_key, members) {
tracing::warn!(
group = %group_name,
previous_size = prev.len(),
"case-insensitive group name collision, overwriting"
);
}
}
let scene_registry = SceneRegistry::new().with_user_scenes(config.scenes())?;
let cancel = CancellationToken::new();
let interval = Duration::from_secs(config.discovery_interval_secs());
let cancel_for_task = cancel.clone();
let registry = Arc::new(Self {
devices,
cloud,
local,
alias_map,
name_map,
group_map,
state_cache: RwLock::new(HashMap::new()),
scene_registry,
cancel,
config,
});
tokio::spawn(reconciliation_loop(
Arc::downgrade(®istry),
cancel_for_task,
interval,
));
Ok(registry)
}
pub fn devices(&self) -> Vec<Device> {
self.devices.values().map(|r| r.device.clone()).collect()
}
pub fn get_device(&self, id: &DeviceId) -> Result<&Device> {
self.devices
.get(id)
.map(|r| &r.device)
.ok_or_else(|| GoveeError::DeviceNotFound(id.to_string()))
}
pub fn resolve(&self, name: &str) -> Result<DeviceId> {
let key = name.to_lowercase();
if let Some(id) = self.name_map.get(&key) {
return Ok(id.clone());
}
if let Some(id) = self.alias_map.get(&key) {
return Ok(id.clone());
}
Err(GoveeError::DeviceNotFound(name.to_string()))
}
#[allow(dead_code)]
pub(crate) fn backend_for(&self, id: &DeviceId) -> Result<&dyn GoveeBackend> {
let reg = self
.devices
.get(id)
.ok_or_else(|| GoveeError::DeviceNotFound(id.to_string()))?;
match reg.active_backend {
BackendType::Cloud => self
.cloud
.as_deref()
.ok_or_else(|| GoveeError::BackendUnavailable("cloud".into())),
BackendType::Local => self
.local
.as_deref()
.ok_or_else(|| GoveeError::BackendUnavailable("local".into())),
}
}
fn fallback_backend(&self, id: &DeviceId) -> Option<&dyn GoveeBackend> {
if self.config.backend() != BackendPreference::Auto {
return None;
}
let reg = self.devices.get(id)?;
match reg.active_backend {
BackendType::Cloud if reg.has_local => self.local.as_deref(),
BackendType::Local if reg.has_cloud => self.cloud.as_deref(),
_ => None,
}
}
fn is_transport_error(err: &GoveeError) -> bool {
matches!(
err,
GoveeError::BackendUnavailable(_)
| GoveeError::Request(_)
| GoveeError::DiscoveryTimeout
| GoveeError::RateLimited { .. }
| GoveeError::Api { code: 500.., .. }
| GoveeError::Io(_)
| GoveeError::Protocol(_)
)
}
pub fn backend_status(&self) -> Vec<(DeviceId, BackendType)> {
self.devices
.iter()
.map(|(id, reg)| (id.clone(), reg.active_backend))
.collect()
}
pub async fn get_state(self: &Arc<Self>, id: &DeviceId) -> Result<DeviceState> {
self.get_device(id)?;
{
let cache = self.state_cache.read().await;
if let Some(entry) = cache.get(id)
&& entry.source != CacheSource::Stale
{
return Ok(entry.state.clone());
}
}
let primary = self.backend_for(id)?;
let state = match primary.get_state(id).await {
Ok(s) => s,
Err(primary_err) => {
if Self::is_transport_error(&primary_err)
&& let Some(fallback) = self.fallback_backend(id)
{
tracing::warn!(
device = %id,
error = %primary_err,
"primary backend failed, falling back to {}",
fallback.backend_type()
);
fallback.get_state(id).await?
} else {
return Err(primary_err);
}
}
};
{
let mut cache = self.state_cache.write().await;
cache.insert(
id.clone(),
CacheEntry {
state: state.clone(),
source: CacheSource::Confirmed,
updated_at: Instant::now(),
},
);
}
Ok(state)
}
pub(crate) async fn update_cache(
&self,
id: &DeviceId,
state: DeviceState,
source: CacheSource,
) {
if !self.devices.contains_key(id) {
return;
}
let mut cache = self.state_cache.write().await;
cache.insert(
id.clone(),
CacheEntry {
state,
source,
updated_at: Instant::now(),
},
);
}
pub async fn set_power(self: &Arc<Self>, id: &DeviceId, on: bool) -> Result<()> {
let backend = self.backend_for(id)?;
if let Err(primary_err) = backend.set_power(id, on).await {
if Self::is_transport_error(&primary_err)
&& let Some(fallback) = self.fallback_backend(id)
{
tracing::warn!(
device = %id,
error = %primary_err,
"primary backend failed, falling back to {}",
fallback.backend_type()
);
fallback.set_power(id, on).await?;
} else {
return Err(primary_err);
}
}
if let Ok(mut state) = self.get_state(id).await {
state.on = on;
state.stale = false;
self.update_cache(id, state, CacheSource::Optimistic).await;
}
Ok(())
}
pub async fn set_brightness(self: &Arc<Self>, id: &DeviceId, value: u8) -> Result<()> {
let backend = self.backend_for(id)?;
if let Err(primary_err) = backend.set_brightness(id, value).await {
if Self::is_transport_error(&primary_err)
&& let Some(fallback) = self.fallback_backend(id)
{
tracing::warn!(
device = %id,
error = %primary_err,
"primary backend failed, falling back to {}",
fallback.backend_type()
);
fallback.set_brightness(id, value).await?;
} else {
return Err(primary_err);
}
}
if let Ok(mut state) = self.get_state(id).await {
state.brightness = value;
state.stale = false;
self.update_cache(id, state, CacheSource::Optimistic).await;
}
Ok(())
}
pub async fn set_color(self: &Arc<Self>, id: &DeviceId, color: Color) -> Result<()> {
let backend = self.backend_for(id)?;
if let Err(primary_err) = backend.set_color(id, color).await {
if Self::is_transport_error(&primary_err)
&& let Some(fallback) = self.fallback_backend(id)
{
tracing::warn!(
device = %id,
error = %primary_err,
"primary backend failed, falling back to {}",
fallback.backend_type()
);
fallback.set_color(id, color).await?;
} else {
return Err(primary_err);
}
}
if let Ok(mut state) = self.get_state(id).await {
state.color = color;
state.color_temp_kelvin = None;
state.stale = false;
self.update_cache(id, state, CacheSource::Optimistic).await;
}
Ok(())
}
pub async fn set_color_temp(self: &Arc<Self>, id: &DeviceId, kelvin: u32) -> Result<()> {
let backend = self.backend_for(id)?;
if let Err(primary_err) = backend.set_color_temp(id, kelvin).await {
if Self::is_transport_error(&primary_err)
&& let Some(fallback) = self.fallback_backend(id)
{
tracing::warn!(
device = %id,
error = %primary_err,
"primary backend failed, falling back to {}",
fallback.backend_type()
);
fallback.set_color_temp(id, kelvin).await?;
} else {
return Err(primary_err);
}
}
if let Ok(mut state) = self.get_state(id).await {
state.color_temp_kelvin = Some(kelvin);
state.color = Color::new(0, 0, 0);
state.stale = false;
self.update_cache(id, state, CacheSource::Optimistic).await;
}
Ok(())
}
pub fn scenes(&self) -> &SceneRegistry {
&self.scene_registry
}
pub async fn apply_scene(
self: &Arc<Self>,
scene_name: &str,
target: SceneTarget,
) -> Result<()> {
let scene = self.scenes().get(scene_name)?;
let brightness = scene.brightness();
let color = *scene.color();
let ids: Vec<DeviceId> = match target {
SceneTarget::Device(id) => vec![id],
SceneTarget::DeviceName(name) => vec![self.resolve(&name)?],
SceneTarget::Group(name) => self.resolve_group(&name)?,
SceneTarget::All => self.devices.keys().cloned().collect(),
};
let results: Vec<_> = futures::future::join_all(ids.iter().map(|id| {
let registry = Arc::clone(self);
let id = id.clone();
async move {
match color {
SceneColor::Rgb(c) => registry.set_color(&id, c).await?,
SceneColor::Temp(k) => registry.set_color_temp(&id, k).await?,
}
registry.set_brightness(&id, brightness).await
}
}))
.await;
if ids.len() == 1 {
return results.into_iter().next().unwrap();
}
collect_group_results(&ids, results)
}
pub fn resolve_group(&self, name: &str) -> Result<Vec<DeviceId>> {
self.group_map
.get(&name.to_lowercase())
.cloned()
.ok_or_else(|| GoveeError::DeviceNotFound(format!("group: {name}")))
}
pub async fn group_set_power(self: &Arc<Self>, group: &str, on: bool) -> Result<()> {
let ids = self.resolve_group(group)?;
if ids.is_empty() {
return Ok(());
}
let results: Vec<_> =
futures::future::join_all(ids.iter().map(|id| self.set_power(id, on))).await;
collect_group_results(&ids, results)
}
pub async fn group_set_brightness(self: &Arc<Self>, group: &str, value: u8) -> Result<()> {
let ids = self.resolve_group(group)?;
if ids.is_empty() {
return Ok(());
}
let results: Vec<_> =
futures::future::join_all(ids.iter().map(|id| self.set_brightness(id, value))).await;
collect_group_results(&ids, results)
}
pub async fn group_set_color(self: &Arc<Self>, group: &str, color: Color) -> Result<()> {
let ids = self.resolve_group(group)?;
if ids.is_empty() {
return Ok(());
}
let results: Vec<_> =
futures::future::join_all(ids.iter().map(|id| self.set_color(id, color))).await;
collect_group_results(&ids, results)
}
pub async fn group_set_color_temp(self: &Arc<Self>, group: &str, kelvin: u32) -> Result<()> {
let ids = self.resolve_group(group)?;
if ids.is_empty() {
return Ok(());
}
let results: Vec<_> =
futures::future::join_all(ids.iter().map(|id| self.set_color_temp(id, kelvin))).await;
collect_group_results(&ids, results)
}
#[cfg(test)]
pub(crate) async fn start_with_backends(
config: Config,
cloud: Option<Arc<dyn GoveeBackend>>,
local: Option<Arc<dyn GoveeBackend>>,
) -> Result<Arc<Self>> {
Self::build(config, cloud, local).await
}
}
fn collect_group_results(ids: &[DeviceId], results: Vec<Result<()>>) -> Result<()> {
let mut succeeded = Vec::new();
let mut failed = Vec::new();
for (id, result) in ids.iter().zip(results) {
match result {
Ok(()) => succeeded.push(id.clone()),
Err(e) => failed.push((id.clone(), Box::new(e))),
}
}
if failed.is_empty() {
Ok(())
} else {
Err(GoveeError::PartialFailure {
succeeded_count: succeeded.len(),
failed_count: failed.len(),
succeeded,
failed,
})
}
}
async fn reconciliation_loop(
weak: Weak<DeviceRegistry>,
cancel: CancellationToken,
interval: Duration,
) {
loop {
tokio::select! {
() = cancel.cancelled() => break,
() = tokio::time::sleep(interval) => {}
}
let registry = match weak.upgrade() {
Some(r) => r,
None => break,
};
let device_ids: Vec<DeviceId> = registry.devices.keys().cloned().collect();
for id in &device_ids {
{
let cache = registry.state_cache.read().await;
if let Some(entry) = cache.get(id)
&& entry.source == CacheSource::Optimistic
&& entry.updated_at.elapsed() < interval
{
continue;
}
}
let backend = match registry.backend_for(id) {
Ok(b) => b,
Err(e) => {
tracing::warn!(device = %id, error = %e, "reconciliation: backend lookup failed");
continue;
}
};
let live_state = match backend.get_state(id).await {
Ok(s) => s,
Err(e) => {
tracing::warn!(device = %id, error = %e, "reconciliation: get_state failed");
continue;
}
};
{
let mut cache = registry.state_cache.write().await;
if let Some(entry) = cache.get(id)
&& entry.source == CacheSource::Optimistic
&& entry.updated_at.elapsed() < interval
{
continue;
}
let diverged = cache.get(id).is_some_and(|entry| {
entry.state.on != live_state.on
|| entry.state.brightness != live_state.brightness
|| entry.state.color != live_state.color
|| entry.state.color_temp_kelvin != live_state.color_temp_kelvin
});
if diverged {
tracing::info!(
device = %id,
"reconciliation: cached state diverged from device"
);
}
let mut confirmed_state = live_state;
confirmed_state.stale = diverged;
cache.insert(
id.clone(),
CacheEntry {
state: confirmed_state,
source: CacheSource::Confirmed,
updated_at: Instant::now(),
},
);
}
}
}
}
impl fmt::Debug for DeviceRegistry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DeviceRegistry")
.field("device_count", &self.devices.len())
.field("cloud", &self.cloud.is_some())
.field("local", &self.local.is_some())
.finish()
}
}
impl Drop for DeviceRegistry {
fn drop(&mut self) {
self.cancel.cancel();
}
}
const _: () = {
fn _assert_send_sync<T: Send + Sync>() {}
fn _assert() {
_assert_send_sync::<DeviceRegistry>();
}
};
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::time::Duration;
use super::*;
use crate::backend::mock::MockBackend;
use crate::types::DeviceState;
fn make_device(mac: &str, model: &str, name: &str, backend: BackendType) -> Device {
Device {
id: DeviceId::new(mac).unwrap(),
model: model.into(),
name: name.into(),
alias: None,
backend,
}
}
fn default_config() -> Config {
Config::default()
}
#[tokio::test]
async fn cloud_only_merge() {
let cloud_devices = vec![
make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Kitchen Light",
BackendType::Cloud,
),
make_device(
"AA:BB:CC:DD:EE:02",
"H6078",
"Bedroom Light",
BackendType::Cloud,
),
];
let cloud = Arc::new(
MockBackend::new()
.with_devices(cloud_devices)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let registry = DeviceRegistry::start_with_backends(default_config(), Some(cloud), None)
.await
.unwrap();
let devices = registry.devices();
assert_eq!(devices.len(), 2);
}
#[tokio::test]
async fn local_only_merge() {
let local_devices = vec![make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"H6076_AABB",
BackendType::Local,
)];
let local = Arc::new(
MockBackend::new()
.with_devices(local_devices)
.with_backend_type(BackendType::Local),
) as Arc<dyn GoveeBackend>;
let registry = DeviceRegistry::start_with_backends(default_config(), None, Some(local))
.await
.unwrap();
let devices = registry.devices();
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].name, "H6076_AABB");
}
#[tokio::test]
async fn overlapping_macs_uses_cloud_name_and_local_backend() {
let mac = "AA:BB:CC:DD:EE:FF";
let cloud_devices = vec![make_device(
mac,
"H6076",
"Kitchen Light",
BackendType::Cloud,
)];
let local_devices = vec![make_device(mac, "H6076", "H6076_AABB", BackendType::Local)];
let cloud = Arc::new(
MockBackend::new()
.with_devices(cloud_devices)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let local = Arc::new(
MockBackend::new()
.with_devices(local_devices)
.with_backend_type(BackendType::Local),
) as Arc<dyn GoveeBackend>;
let registry =
DeviceRegistry::start_with_backends(default_config(), Some(cloud), Some(local))
.await
.unwrap();
let devices = registry.devices();
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].name, "Kitchen Light");
let id = DeviceId::new(mac).unwrap();
let backend = registry.backend_for(&id).unwrap();
assert_eq!(backend.backend_type(), BackendType::Local);
}
#[tokio::test]
async fn disjoint_devices_all_included() {
let cloud_devices = vec![make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Cloud Only",
BackendType::Cloud,
)];
let local_devices = vec![make_device(
"AA:BB:CC:DD:EE:02",
"H6078",
"Local Only",
BackendType::Local,
)];
let cloud = Arc::new(
MockBackend::new()
.with_devices(cloud_devices)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let local = Arc::new(
MockBackend::new()
.with_devices(local_devices)
.with_backend_type(BackendType::Local),
) as Arc<dyn GoveeBackend>;
let registry =
DeviceRegistry::start_with_backends(default_config(), Some(cloud), Some(local))
.await
.unwrap();
assert_eq!(registry.devices().len(), 2);
let cloud_id = DeviceId::new("AA:BB:CC:DD:EE:01").unwrap();
let local_id = DeviceId::new("AA:BB:CC:DD:EE:02").unwrap();
assert_eq!(
registry.backend_for(&cloud_id).unwrap().backend_type(),
BackendType::Cloud
);
assert_eq!(
registry.backend_for(&local_id).unwrap().backend_type(),
BackendType::Local
);
}
#[tokio::test]
async fn no_backends_empty_registry() {
let registry = DeviceRegistry::start_with_backends(default_config(), None, None)
.await
.unwrap();
assert!(registry.devices().is_empty());
}
#[tokio::test]
async fn get_device_existing() {
let mac = "AA:BB:CC:DD:EE:FF";
let cloud = Arc::new(
MockBackend::new()
.with_devices(vec![make_device(mac, "H6076", "Light", BackendType::Cloud)])
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let registry = DeviceRegistry::start_with_backends(default_config(), Some(cloud), None)
.await
.unwrap();
let id = DeviceId::new(mac).unwrap();
let device = registry.get_device(&id).unwrap();
assert_eq!(device.name, "Light");
}
#[tokio::test]
async fn get_device_not_found() {
let registry = DeviceRegistry::start_with_backends(default_config(), None, None)
.await
.unwrap();
let id = DeviceId::new("AA:BB:CC:DD:EE:FF").unwrap();
assert!(registry.get_device(&id).is_err());
}
#[tokio::test]
async fn backend_for_unknown_device() {
let registry = DeviceRegistry::start_with_backends(default_config(), None, None)
.await
.unwrap();
let id = DeviceId::new("AA:BB:CC:DD:EE:FF").unwrap();
let result = registry.backend_for(&id);
assert!(result.is_err());
}
#[tokio::test]
async fn backend_for_routes_to_cloud() {
let cloud_devices = vec![make_device(
"AA:BB:CC:DD:EE:FF",
"H6076",
"Light",
BackendType::Cloud,
)];
let cloud = Arc::new(
MockBackend::new()
.with_devices(cloud_devices)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let registry = DeviceRegistry::start_with_backends(default_config(), Some(cloud), None)
.await
.unwrap();
let id = DeviceId::new("AA:BB:CC:DD:EE:FF").unwrap();
assert_eq!(
registry.backend_for(&id).unwrap().backend_type(),
BackendType::Cloud
);
}
#[tokio::test]
async fn backend_for_routes_to_local() {
let local_devices = vec![make_device(
"AA:BB:CC:DD:EE:FF",
"H6076",
"Light",
BackendType::Local,
)];
let local = Arc::new(
MockBackend::new()
.with_devices(local_devices)
.with_backend_type(BackendType::Local),
) as Arc<dyn GoveeBackend>;
let registry = DeviceRegistry::start_with_backends(default_config(), None, Some(local))
.await
.unwrap();
let id = DeviceId::new("AA:BB:CC:DD:EE:FF").unwrap();
assert_eq!(
registry.backend_for(&id).unwrap().backend_type(),
BackendType::Local
);
}
#[tokio::test]
async fn backend_for_unavailable_returns_error() {
let cloud = Arc::new(
MockBackend::new()
.with_devices(vec![make_device(
"AA:BB:CC:DD:EE:FF",
"H6076",
"Light",
BackendType::Cloud,
)])
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let registry = DeviceRegistry::start_with_backends(default_config(), Some(cloud), None)
.await
.unwrap();
let id = DeviceId::new("AA:BB:CC:DD:EE:FF").unwrap();
assert!(registry.backend_for(&id).is_ok());
let unknown = DeviceId::new("11:22:33:44:55:66").unwrap();
let err = registry.backend_for(&unknown).err().unwrap();
assert!(matches!(err, GoveeError::DeviceNotFound(_)));
}
#[tokio::test]
async fn debug_format() {
let cloud = Arc::new(
MockBackend::new()
.with_devices(vec![
make_device("AA:BB:CC:DD:EE:01", "H6076", "Light 1", BackendType::Cloud),
make_device("AA:BB:CC:DD:EE:02", "H6078", "Light 2", BackendType::Cloud),
])
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let registry = DeviceRegistry::start_with_backends(default_config(), Some(cloud), None)
.await
.unwrap();
let debug = format!("{:?}", registry);
assert!(debug.contains("device_count: 2"));
assert!(debug.contains("cloud: true"));
assert!(debug.contains("local: false"));
}
#[tokio::test]
async fn overlapping_device_backend_field_updated() {
let mac = "AA:BB:CC:DD:EE:FF";
let cloud_devices = vec![make_device(mac, "H6076", "Light", BackendType::Cloud)];
let local_devices = vec![make_device(mac, "H6076", "H6076_X", BackendType::Local)];
let cloud = Arc::new(
MockBackend::new()
.with_devices(cloud_devices)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let local = Arc::new(
MockBackend::new()
.with_devices(local_devices)
.with_backend_type(BackendType::Local),
) as Arc<dyn GoveeBackend>;
let registry =
DeviceRegistry::start_with_backends(default_config(), Some(cloud), Some(local))
.await
.unwrap();
let id = DeviceId::new(mac).unwrap();
let device = registry.get_device(&id).unwrap();
assert_eq!(device.backend, BackendType::Local);
}
#[tokio::test]
async fn cloud_only_without_api_key_is_error() {
let config = Config::new(
None,
BackendPreference::CloudOnly,
60,
HashMap::new(),
HashMap::new(),
HashMap::new(),
)
.unwrap();
let result = DeviceRegistry::start(config).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("CloudOnly"));
assert!(err.contains("API key"));
}
#[tokio::test]
async fn auto_mode_local_for_discovered_cloud_for_rest() {
let cloud_devices = vec![
make_device("AA:BB:CC:DD:EE:01", "H6076", "Light A", BackendType::Cloud),
make_device("AA:BB:CC:DD:EE:02", "H6078", "Light B", BackendType::Cloud),
];
let local_devices = vec![make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"H6076_X",
BackendType::Local,
)];
let cloud = Arc::new(
MockBackend::new()
.with_devices(cloud_devices)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let local = Arc::new(
MockBackend::new()
.with_devices(local_devices)
.with_backend_type(BackendType::Local),
) as Arc<dyn GoveeBackend>;
let registry =
DeviceRegistry::start_with_backends(default_config(), Some(cloud), Some(local))
.await
.unwrap();
let id_local = DeviceId::new("AA:BB:CC:DD:EE:01").unwrap();
let id_cloud = DeviceId::new("AA:BB:CC:DD:EE:02").unwrap();
assert_eq!(
registry.backend_for(&id_local).unwrap().backend_type(),
BackendType::Local
);
assert_eq!(
registry.backend_for(&id_cloud).unwrap().backend_type(),
BackendType::Cloud
);
}
#[tokio::test]
async fn cloud_only_mode_all_cloud() {
let cloud_devices = vec![
make_device("AA:BB:CC:DD:EE:01", "H6076", "Light A", BackendType::Cloud),
make_device("AA:BB:CC:DD:EE:02", "H6078", "Light B", BackendType::Cloud),
];
let local_devices = vec![make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"H6076_X",
BackendType::Local,
)];
let cloud = Arc::new(
MockBackend::new()
.with_devices(cloud_devices)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let local = Arc::new(
MockBackend::new()
.with_devices(local_devices)
.with_backend_type(BackendType::Local),
) as Arc<dyn GoveeBackend>;
let config = Config::new(
Some("test-key".into()),
BackendPreference::CloudOnly,
60,
HashMap::new(),
HashMap::new(),
HashMap::new(),
)
.unwrap();
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), Some(local))
.await
.unwrap();
let devices = registry.devices();
assert_eq!(devices.len(), 2);
for dev in &devices {
assert_eq!(dev.backend, BackendType::Cloud);
}
}
#[tokio::test]
async fn local_only_mode_excludes_cloud_only_devices() {
let cloud_devices = vec![
make_device("AA:BB:CC:DD:EE:01", "H6076", "Light A", BackendType::Cloud),
make_device("AA:BB:CC:DD:EE:02", "H6078", "Light B", BackendType::Cloud),
];
let local_devices = vec![make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"H6076_X",
BackendType::Local,
)];
let cloud = Arc::new(
MockBackend::new()
.with_devices(cloud_devices)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let local = Arc::new(
MockBackend::new()
.with_devices(local_devices)
.with_backend_type(BackendType::Local),
) as Arc<dyn GoveeBackend>;
let config = Config::new(
None,
BackendPreference::LocalOnly,
60,
HashMap::new(),
HashMap::new(),
HashMap::new(),
)
.unwrap();
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), Some(local))
.await
.unwrap();
let devices = registry.devices();
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].id, DeviceId::new("AA:BB:CC:DD:EE:01").unwrap());
assert_eq!(devices[0].backend, BackendType::Local);
let cloud_only_id = DeviceId::new("AA:BB:CC:DD:EE:02").unwrap();
assert!(registry.get_device(&cloud_only_id).is_err());
}
#[tokio::test]
async fn auto_no_api_key_local_only() {
let local_devices = vec![make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"H6076_X",
BackendType::Local,
)];
let local = Arc::new(
MockBackend::new()
.with_devices(local_devices)
.with_backend_type(BackendType::Local),
) as Arc<dyn GoveeBackend>;
let registry = DeviceRegistry::start_with_backends(default_config(), None, Some(local))
.await
.unwrap();
let devices = registry.devices();
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].backend, BackendType::Local);
}
#[tokio::test]
async fn backend_status_reflects_assignments() {
let cloud_devices = vec![
make_device("AA:BB:CC:DD:EE:01", "H6076", "Light A", BackendType::Cloud),
make_device("AA:BB:CC:DD:EE:02", "H6078", "Light B", BackendType::Cloud),
];
let local_devices = vec![make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"H6076_X",
BackendType::Local,
)];
let cloud = Arc::new(
MockBackend::new()
.with_devices(cloud_devices)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let local = Arc::new(
MockBackend::new()
.with_devices(local_devices)
.with_backend_type(BackendType::Local),
) as Arc<dyn GoveeBackend>;
let registry =
DeviceRegistry::start_with_backends(default_config(), Some(cloud), Some(local))
.await
.unwrap();
let status = registry.backend_status();
assert_eq!(status.len(), 2);
let status_map: HashMap<DeviceId, BackendType> = status.into_iter().collect();
let id1 = DeviceId::new("AA:BB:CC:DD:EE:01").unwrap();
let id2 = DeviceId::new("AA:BB:CC:DD:EE:02").unwrap();
assert_eq!(status_map[&id1], BackendType::Local);
assert_eq!(status_map[&id2], BackendType::Cloud);
}
#[tokio::test]
async fn arc_clone_shares_registry() {
let cloud = Arc::new(
MockBackend::new()
.with_devices(vec![make_device(
"AA:BB:CC:DD:EE:FF",
"H6076",
"Light",
BackendType::Cloud,
)])
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let registry = DeviceRegistry::start_with_backends(default_config(), Some(cloud), None)
.await
.unwrap();
let clone = Arc::clone(®istry);
assert_eq!(registry.devices().len(), clone.devices().len());
}
#[tokio::test]
async fn resolve_by_canonical_name_exact_case() {
let cloud = Arc::new(
MockBackend::new()
.with_devices(vec![make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Kitchen Light",
BackendType::Cloud,
)])
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let registry = DeviceRegistry::start_with_backends(default_config(), Some(cloud), None)
.await
.unwrap();
let id = registry.resolve("Kitchen Light").unwrap();
assert_eq!(id, DeviceId::new("AA:BB:CC:DD:EE:01").unwrap());
}
#[tokio::test]
async fn resolve_by_canonical_name_different_case() {
let cloud = Arc::new(
MockBackend::new()
.with_devices(vec![make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Kitchen Light",
BackendType::Cloud,
)])
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let registry = DeviceRegistry::start_with_backends(default_config(), Some(cloud), None)
.await
.unwrap();
let id = registry.resolve("kitchen light").unwrap();
assert_eq!(id, DeviceId::new("AA:BB:CC:DD:EE:01").unwrap());
let id = registry.resolve("KITCHEN LIGHT").unwrap();
assert_eq!(id, DeviceId::new("AA:BB:CC:DD:EE:01").unwrap());
}
#[tokio::test]
async fn resolve_by_alias() {
let mut aliases = HashMap::new();
aliases.insert("kitchen".to_string(), "Kitchen Light".to_string());
let config = Config::new(
None,
BackendPreference::Auto,
60,
aliases,
HashMap::new(),
HashMap::new(),
)
.unwrap();
let cloud = Arc::new(
MockBackend::new()
.with_devices(vec![make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Kitchen Light",
BackendType::Cloud,
)])
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), None)
.await
.unwrap();
let id = registry.resolve("kitchen").unwrap();
assert_eq!(id, DeviceId::new("AA:BB:CC:DD:EE:01").unwrap());
}
#[tokio::test]
async fn resolve_unknown_name_returns_error() {
let cloud = Arc::new(
MockBackend::new()
.with_devices(vec![make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Kitchen Light",
BackendType::Cloud,
)])
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let registry = DeviceRegistry::start_with_backends(default_config(), Some(cloud), None)
.await
.unwrap();
let err = registry.resolve("Nonexistent").unwrap_err();
assert!(matches!(err, GoveeError::DeviceNotFound(name) if name == "Nonexistent"));
}
#[tokio::test]
async fn resolve_multiple_aliases_same_device() {
let mut aliases = HashMap::new();
aliases.insert("kitchen".to_string(), "Kitchen Light".to_string());
aliases.insert("k".to_string(), "Kitchen Light".to_string());
let config = Config::new(
None,
BackendPreference::Auto,
60,
aliases,
HashMap::new(),
HashMap::new(),
)
.unwrap();
let cloud = Arc::new(
MockBackend::new()
.with_devices(vec![make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Kitchen Light",
BackendType::Cloud,
)])
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), None)
.await
.unwrap();
let expected = DeviceId::new("AA:BB:CC:DD:EE:01").unwrap();
assert_eq!(registry.resolve("kitchen").unwrap(), expected);
assert_eq!(registry.resolve("k").unwrap(), expected);
}
#[tokio::test]
async fn resolve_alias_target_not_found_not_registered() {
let mut aliases = HashMap::new();
aliases.insert("ghost".to_string(), "Does Not Exist".to_string());
let config = Config::new(
None,
BackendPreference::Auto,
60,
aliases,
HashMap::new(),
HashMap::new(),
)
.unwrap();
let cloud = Arc::new(
MockBackend::new()
.with_devices(vec![make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Kitchen Light",
BackendType::Cloud,
)])
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), None)
.await
.unwrap();
let err = registry.resolve("ghost").unwrap_err();
assert!(matches!(err, GoveeError::DeviceNotFound(_)));
}
#[tokio::test]
async fn resolve_name_collision_last_by_id_wins() {
let cloud = Arc::new(
MockBackend::new()
.with_devices(vec![
make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Living Room",
BackendType::Cloud,
),
make_device(
"AA:BB:CC:DD:EE:02",
"H6078",
"Living Room",
BackendType::Cloud,
),
])
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let registry = DeviceRegistry::start_with_backends(default_config(), Some(cloud), None)
.await
.unwrap();
let id = registry.resolve("Living Room").unwrap();
assert_eq!(id, DeviceId::new("AA:BB:CC:DD:EE:02").unwrap());
}
fn make_state(on: bool, brightness: u8, r: u8, g: u8, b: u8) -> DeviceState {
use crate::types::Color;
DeviceState::new(on, brightness, Color::new(r, g, b), None, false).unwrap()
}
fn mock_with_device_and_state(
mac: &str,
state: DeviceState,
) -> (Arc<dyn GoveeBackend>, DeviceId) {
let id = DeviceId::new(mac).unwrap();
let device = make_device(mac, "H6076", "Test Light", BackendType::Cloud);
let backend = Arc::new(
MockBackend::new()
.with_devices(vec![device])
.with_state(state)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
(backend, id)
}
#[tokio::test]
async fn cache_miss_queries_backend() {
let state = make_state(true, 75, 255, 0, 0);
let (cloud, id) = mock_with_device_and_state("AA:BB:CC:DD:EE:01", state);
let registry = DeviceRegistry::start_with_backends(default_config(), Some(cloud), None)
.await
.unwrap();
let result = registry.get_state(&id).await.unwrap();
assert!(result.on);
assert_eq!(result.brightness, 75);
assert_eq!(result.color.r, 255);
assert_eq!(result.color.g, 0);
assert_eq!(result.color.b, 0);
}
#[tokio::test]
async fn cache_hit_returns_cached() {
let state = make_state(true, 50, 0, 255, 0);
let (cloud, id) = mock_with_device_and_state("AA:BB:CC:DD:EE:02", state);
let registry = DeviceRegistry::start_with_backends(default_config(), Some(cloud), None)
.await
.unwrap();
let first = registry.get_state(&id).await.unwrap();
assert_eq!(first.brightness, 50);
let second = registry.get_state(&id).await.unwrap();
assert_eq!(second.brightness, 50);
assert_eq!(second.color.g, 255);
}
#[tokio::test]
async fn update_cache_optimistic_reflected_in_get_state() {
let state = make_state(true, 50, 0, 255, 0);
let (cloud, id) = mock_with_device_and_state("AA:BB:CC:DD:EE:03", state);
let registry = DeviceRegistry::start_with_backends(default_config(), Some(cloud), None)
.await
.unwrap();
let optimistic = make_state(false, 100, 0, 0, 255);
registry
.update_cache(&id, optimistic, CacheSource::Optimistic)
.await;
let result = registry.get_state(&id).await.unwrap();
assert!(!result.on);
assert_eq!(result.brightness, 100);
assert_eq!(result.color.b, 255);
}
#[tokio::test]
async fn stale_cache_requeries_backend() {
let state = make_state(true, 80, 128, 0, 0);
let (cloud, id) = mock_with_device_and_state("AA:BB:CC:DD:EE:04", state);
let registry = DeviceRegistry::start_with_backends(default_config(), Some(cloud), None)
.await
.unwrap();
let stale_state = make_state(false, 0, 0, 0, 0);
registry
.update_cache(&id, stale_state, CacheSource::Stale)
.await;
let result = registry.get_state(&id).await.unwrap();
assert!(result.on);
assert_eq!(result.brightness, 80);
assert_eq!(result.color.r, 128);
}
#[tokio::test]
async fn reconciliation_exits_when_weak_is_dead() {
let state = make_state(true, 50, 0, 255, 0);
let device = make_device(
"AA:BB:CC:DD:EE:05",
"H6076",
"Test Light",
BackendType::Cloud,
);
let cloud = Arc::new(
MockBackend::new()
.with_devices(vec![device])
.with_state(state)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let cancel = CancellationToken::new();
let registry = DeviceRegistry::start_with_backends(default_config(), Some(cloud), None)
.await
.unwrap();
let weak = Arc::downgrade(®istry);
let cancel_clone = cancel.clone();
let handle = tokio::spawn(reconciliation_loop(
weak,
cancel_clone,
Duration::from_millis(10),
));
drop(registry);
tokio::time::timeout(Duration::from_secs(2), handle)
.await
.expect("reconciliation task should complete")
.expect("reconciliation task should not panic");
cancel.cancel();
}
#[tokio::test]
async fn reconciliation_confirms_matching_state() {
let state = make_state(true, 75, 255, 0, 0);
let (cloud, id) = mock_with_device_and_state("AA:BB:CC:DD:EE:06", state.clone());
let config = Config::new(
None,
BackendPreference::Auto,
5,
HashMap::new(),
HashMap::new(),
HashMap::new(),
)
.unwrap();
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), None)
.await
.unwrap();
let matching = make_state(true, 75, 255, 0, 0);
registry
.update_cache(&id, matching, CacheSource::Optimistic)
.await;
{
let mut cache = registry.state_cache.write().await;
if let Some(entry) = cache.get_mut(&id) {
entry.updated_at = Instant::now() - Duration::from_secs(60);
}
}
let weak = Arc::downgrade(®istry);
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
let handle = tokio::spawn(async move {
reconciliation_loop(weak, cancel_clone, Duration::from_millis(1)).await;
});
tokio::time::sleep(Duration::from_millis(50)).await;
cancel.cancel();
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
let cache = registry.state_cache.read().await;
let entry = cache.get(&id).unwrap();
assert_eq!(entry.source, CacheSource::Confirmed);
assert!(!entry.state.stale);
}
#[tokio::test]
async fn reconciliation_detects_divergence() {
let backend_state = make_state(true, 80, 0, 255, 0);
let (cloud, id) = mock_with_device_and_state("AA:BB:CC:DD:EE:07", backend_state);
let config = Config::new(
None,
BackendPreference::Auto,
5,
HashMap::new(),
HashMap::new(),
HashMap::new(),
)
.unwrap();
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), None)
.await
.unwrap();
let optimistic = make_state(false, 20, 255, 0, 0);
registry
.update_cache(&id, optimistic, CacheSource::Optimistic)
.await;
{
let cache = registry.state_cache.read().await;
let entry = cache.get(&id).unwrap();
assert_eq!(entry.source, CacheSource::Optimistic);
assert!(!entry.state.on);
assert_eq!(entry.state.brightness, 20);
}
{
let mut cache = registry.state_cache.write().await;
if let Some(entry) = cache.get_mut(&id) {
entry.updated_at = Instant::now() - Duration::from_secs(60);
}
}
let weak = Arc::downgrade(®istry);
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
let handle = tokio::spawn(async move {
reconciliation_loop(weak, cancel_clone, Duration::from_millis(500)).await;
});
tokio::time::sleep(Duration::from_millis(600)).await;
cancel.cancel();
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
let cache = registry.state_cache.read().await;
let entry = cache.get(&id).unwrap();
assert_eq!(entry.source, CacheSource::Confirmed);
assert!(
entry.state.stale,
"expected stale=true after divergence; cached: on={}, brightness={}, color=({},{},{}); live: on=true, brightness=80, color=(0,255,0)",
entry.state.on,
entry.state.brightness,
entry.state.color.r,
entry.state.color.g,
entry.state.color.b
);
assert!(entry.state.on);
assert_eq!(entry.state.brightness, 80);
assert_eq!(entry.state.color.g, 255);
}
#[tokio::test]
async fn update_cache_ignores_unknown_device() {
let registry = DeviceRegistry::start_with_backends(default_config(), None, None)
.await
.unwrap();
let unknown = DeviceId::new("FF:FF:FF:FF:FF:FF").unwrap();
let state = make_state(true, 50, 0, 0, 0);
registry
.update_cache(&unknown, state, CacheSource::Optimistic)
.await;
let cache = registry.state_cache.read().await;
assert!(cache.is_empty());
}
#[tokio::test]
async fn resolve_alias_case_insensitive() {
let mut aliases = HashMap::new();
aliases.insert("kitchen".to_string(), "Kitchen Light".to_string());
let config = Config::new(
None,
BackendPreference::Auto,
60,
aliases,
HashMap::new(),
HashMap::new(),
)
.unwrap();
let cloud = Arc::new(
MockBackend::new()
.with_devices(vec![make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Kitchen Light",
BackendType::Cloud,
)])
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), None)
.await
.unwrap();
let expected = DeviceId::new("AA:BB:CC:DD:EE:01").unwrap();
assert_eq!(registry.resolve("KITCHEN").unwrap(), expected);
assert_eq!(registry.resolve("Kitchen").unwrap(), expected);
}
#[tokio::test]
async fn set_power_updates_cache_optimistically() {
let state = make_state(true, 50, 255, 0, 0);
let (cloud, id) = mock_with_device_and_state("AA:BB:CC:DD:EE:01", state);
let registry = DeviceRegistry::start_with_backends(default_config(), Some(cloud), None)
.await
.unwrap();
registry.set_power(&id, false).await.unwrap();
let cached = registry.get_state(&id).await.unwrap();
assert!(!cached.on);
assert_eq!(cached.brightness, 50);
assert_eq!(cached.color.r, 255);
}
#[tokio::test]
async fn set_brightness_updates_cache_optimistically() {
let state = make_state(true, 50, 0, 255, 0);
let (cloud, id) = mock_with_device_and_state("AA:BB:CC:DD:EE:02", state);
let registry = DeviceRegistry::start_with_backends(default_config(), Some(cloud), None)
.await
.unwrap();
registry.set_brightness(&id, 80).await.unwrap();
let cached = registry.get_state(&id).await.unwrap();
assert_eq!(cached.brightness, 80);
assert!(cached.on);
assert_eq!(cached.color.g, 255);
}
#[tokio::test]
async fn set_color_updates_color_and_clears_temp() {
use crate::types::Color;
let state = DeviceState::new(true, 50, Color::new(0, 0, 0), Some(4000), false).unwrap();
let (cloud, id) = mock_with_device_and_state("AA:BB:CC:DD:EE:03", state);
let registry = DeviceRegistry::start_with_backends(default_config(), Some(cloud), None)
.await
.unwrap();
registry
.set_color(&id, Color::new(255, 128, 0))
.await
.unwrap();
let cached = registry.get_state(&id).await.unwrap();
assert_eq!(cached.color, Color::new(255, 128, 0));
assert_eq!(cached.color_temp_kelvin, None);
assert!(cached.on);
assert_eq!(cached.brightness, 50);
}
#[tokio::test]
async fn set_color_temp_updates_temp_and_resets_color() {
use crate::types::Color;
let state = make_state(true, 50, 255, 128, 0);
let (cloud, id) = mock_with_device_and_state("AA:BB:CC:DD:EE:04", state);
let registry = DeviceRegistry::start_with_backends(default_config(), Some(cloud), None)
.await
.unwrap();
registry.set_color_temp(&id, 5000).await.unwrap();
let cached = registry.get_state(&id).await.unwrap();
assert_eq!(cached.color_temp_kelvin, Some(5000));
assert_eq!(cached.color, Color::new(0, 0, 0));
assert!(cached.on);
assert_eq!(cached.brightness, 50);
}
#[tokio::test]
async fn set_power_unknown_device_returns_error() {
let registry = DeviceRegistry::start_with_backends(default_config(), None, None)
.await
.unwrap();
let unknown = DeviceId::new("FF:FF:FF:FF:FF:FF").unwrap();
let err = registry.set_power(&unknown, true).await.unwrap_err();
assert!(matches!(err, GoveeError::DeviceNotFound(_)));
}
#[tokio::test]
async fn set_brightness_unknown_device_returns_error() {
let registry = DeviceRegistry::start_with_backends(default_config(), None, None)
.await
.unwrap();
let unknown = DeviceId::new("FF:FF:FF:FF:FF:FF").unwrap();
let err = registry.set_brightness(&unknown, 50).await.unwrap_err();
assert!(matches!(err, GoveeError::DeviceNotFound(_)));
}
#[tokio::test]
async fn set_color_unknown_device_returns_error() {
use crate::types::Color;
let registry = DeviceRegistry::start_with_backends(default_config(), None, None)
.await
.unwrap();
let unknown = DeviceId::new("FF:FF:FF:FF:FF:FF").unwrap();
let err = registry
.set_color(&unknown, Color::new(255, 0, 0))
.await
.unwrap_err();
assert!(matches!(err, GoveeError::DeviceNotFound(_)));
}
#[tokio::test]
async fn set_color_temp_unknown_device_returns_error() {
let registry = DeviceRegistry::start_with_backends(default_config(), None, None)
.await
.unwrap();
let unknown = DeviceId::new("FF:FF:FF:FF:FF:FF").unwrap();
let err = registry.set_color_temp(&unknown, 4000).await.unwrap_err();
assert!(matches!(err, GoveeError::DeviceNotFound(_)));
}
fn config_with_groups(groups: HashMap<String, Vec<String>>) -> Config {
Config::new(
None,
BackendPreference::Auto,
60,
HashMap::new(),
groups,
HashMap::new(),
)
.unwrap()
}
#[tokio::test]
async fn resolve_group_known_group() {
let cloud_devices = vec![
make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Kitchen Light",
BackendType::Cloud,
),
make_device(
"AA:BB:CC:DD:EE:02",
"H6078",
"Bedroom Light",
BackendType::Cloud,
),
];
let cloud = Arc::new(
MockBackend::new()
.with_devices(cloud_devices)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let mut groups = HashMap::new();
groups.insert(
"all".to_string(),
vec!["Kitchen Light".to_string(), "Bedroom Light".to_string()],
);
let config = config_with_groups(groups);
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), None)
.await
.unwrap();
let ids = registry.resolve_group("all").unwrap();
assert_eq!(ids.len(), 2);
}
#[tokio::test]
async fn resolve_group_case_insensitive() {
let cloud_devices = vec![make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Kitchen Light",
BackendType::Cloud,
)];
let cloud = Arc::new(
MockBackend::new()
.with_devices(cloud_devices)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let mut groups = HashMap::new();
groups.insert("MyGroup".to_string(), vec!["Kitchen Light".to_string()]);
let config = config_with_groups(groups);
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), None)
.await
.unwrap();
let ids = registry.resolve_group("MYGROUP").unwrap();
assert_eq!(ids.len(), 1);
}
#[tokio::test]
async fn resolve_group_unknown_returns_error() {
let registry = DeviceRegistry::start_with_backends(default_config(), None, None)
.await
.unwrap();
let err = registry.resolve_group("nonexistent").unwrap_err();
assert!(matches!(err, GoveeError::DeviceNotFound(ref s) if s.contains("group")));
}
#[tokio::test]
async fn group_unresolvable_member_excluded() {
let cloud_devices = vec![make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Kitchen Light",
BackendType::Cloud,
)];
let cloud = Arc::new(
MockBackend::new()
.with_devices(cloud_devices)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let mut groups = HashMap::new();
groups.insert(
"partial".to_string(),
vec![
"Kitchen Light".to_string(),
"Nonexistent Device".to_string(),
],
);
let config = config_with_groups(groups);
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), None)
.await
.unwrap();
let ids = registry.resolve_group("partial").unwrap();
assert_eq!(ids.len(), 1);
assert_eq!(ids[0], DeviceId::new("AA:BB:CC:DD:EE:01").unwrap());
}
#[tokio::test]
async fn group_set_power_empty_group_ok() {
let mut groups = HashMap::new();
groups.insert("empty".to_string(), vec!["No Such Device".to_string()]);
let config = config_with_groups(groups);
let registry = DeviceRegistry::start_with_backends(config, None, None)
.await
.unwrap();
registry.group_set_power("empty", true).await.unwrap();
}
#[tokio::test]
async fn group_set_power_all_succeed() {
let state = make_state(false, 50, 0, 0, 0);
let cloud_devices = vec![
make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Kitchen Light",
BackendType::Cloud,
),
make_device(
"AA:BB:CC:DD:EE:02",
"H6078",
"Bedroom Light",
BackendType::Cloud,
),
];
let cloud = Arc::new(
MockBackend::new()
.with_devices(cloud_devices)
.with_state(state)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let mut groups = HashMap::new();
groups.insert(
"all".to_string(),
vec!["Kitchen Light".to_string(), "Bedroom Light".to_string()],
);
let config = config_with_groups(groups);
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), None)
.await
.unwrap();
registry.group_set_power("all", true).await.unwrap();
}
#[tokio::test]
async fn group_set_brightness_all_succeed() {
let state = make_state(true, 50, 0, 0, 0);
let cloud_devices = vec![make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Kitchen Light",
BackendType::Cloud,
)];
let cloud = Arc::new(
MockBackend::new()
.with_devices(cloud_devices)
.with_state(state)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let mut groups = HashMap::new();
groups.insert("lights".to_string(), vec!["Kitchen Light".to_string()]);
let config = config_with_groups(groups);
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), None)
.await
.unwrap();
registry.group_set_brightness("lights", 80).await.unwrap();
}
#[tokio::test]
async fn group_set_color_all_succeed() {
use crate::types::Color;
let state = make_state(true, 50, 0, 0, 0);
let cloud_devices = vec![make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Kitchen Light",
BackendType::Cloud,
)];
let cloud = Arc::new(
MockBackend::new()
.with_devices(cloud_devices)
.with_state(state)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let mut groups = HashMap::new();
groups.insert("lights".to_string(), vec!["Kitchen Light".to_string()]);
let config = config_with_groups(groups);
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), None)
.await
.unwrap();
registry
.group_set_color("lights", Color::new(255, 0, 0))
.await
.unwrap();
}
#[tokio::test]
async fn group_set_color_temp_all_succeed() {
let state = make_state(true, 50, 0, 0, 0);
let cloud_devices = vec![make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Kitchen Light",
BackendType::Cloud,
)];
let cloud = Arc::new(
MockBackend::new()
.with_devices(cloud_devices)
.with_state(state)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let mut groups = HashMap::new();
groups.insert("lights".to_string(), vec!["Kitchen Light".to_string()]);
let config = config_with_groups(groups);
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), None)
.await
.unwrap();
registry.group_set_color_temp("lights", 4000).await.unwrap();
}
#[tokio::test]
async fn group_command_unknown_group_returns_error() {
let registry = DeviceRegistry::start_with_backends(default_config(), None, None)
.await
.unwrap();
let err = registry
.group_set_power("nonexistent", true)
.await
.unwrap_err();
assert!(matches!(err, GoveeError::DeviceNotFound(_)));
}
#[tokio::test]
async fn group_resolves_alias_members() {
let mut aliases = HashMap::new();
aliases.insert("bed".to_string(), "Bedroom Light".to_string());
let mut groups = HashMap::new();
groups.insert("upstairs".to_string(), vec!["bed".to_string()]);
let config = Config::new(
None,
BackendPreference::Auto,
60,
aliases,
groups,
HashMap::new(),
)
.unwrap();
let cloud = Arc::new(
MockBackend::new()
.with_devices(vec![make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Bedroom Light",
BackendType::Cloud,
)])
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), None)
.await
.unwrap();
let members = registry.resolve_group("upstairs").unwrap();
assert_eq!(members.len(), 1);
assert_eq!(members[0], DeviceId::new("AA:BB:CC:DD:EE:01").unwrap());
}
#[tokio::test]
async fn group_deduplicates_members() {
let mut aliases = HashMap::new();
aliases.insert("bed".to_string(), "Bedroom Light".to_string());
let mut groups = HashMap::new();
groups.insert(
"upstairs".to_string(),
vec!["Bedroom Light".to_string(), "bed".to_string()],
);
let config = Config::new(
None,
BackendPreference::Auto,
60,
aliases,
groups,
HashMap::new(),
)
.unwrap();
let cloud = Arc::new(
MockBackend::new()
.with_devices(vec![make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Bedroom Light",
BackendType::Cloud,
)])
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), None)
.await
.unwrap();
let members = registry.resolve_group("upstairs").unwrap();
assert_eq!(members.len(), 1);
}
#[tokio::test]
async fn group_partial_failure() {
use async_trait::async_trait;
struct SelectiveBackend {
ok_device: DeviceId,
state: DeviceState,
}
#[async_trait]
impl GoveeBackend for SelectiveBackend {
async fn list_devices(&self) -> crate::error::Result<Vec<Device>> {
Ok(vec![
Device {
id: self.ok_device.clone(),
model: "H6076".into(),
name: "Test Light".into(),
alias: None,
backend: BackendType::Cloud,
},
Device {
id: DeviceId::new("AA:BB:CC:DD:EE:02").unwrap(),
model: "H6078".into(),
name: "Failing Light".into(),
alias: None,
backend: BackendType::Cloud,
},
])
}
async fn get_state(&self, id: &DeviceId) -> crate::error::Result<DeviceState> {
if *id == self.ok_device {
Ok(self.state.clone())
} else {
Err(GoveeError::DiscoveryTimeout)
}
}
async fn set_power(&self, id: &DeviceId, _on: bool) -> crate::error::Result<()> {
if *id == self.ok_device {
Ok(())
} else {
Err(GoveeError::DiscoveryTimeout)
}
}
async fn set_brightness(&self, id: &DeviceId, _value: u8) -> crate::error::Result<()> {
if *id == self.ok_device {
Ok(())
} else {
Err(GoveeError::DiscoveryTimeout)
}
}
async fn set_color(&self, id: &DeviceId, _color: Color) -> crate::error::Result<()> {
if *id == self.ok_device {
Ok(())
} else {
Err(GoveeError::DiscoveryTimeout)
}
}
async fn set_color_temp(
&self,
id: &DeviceId,
_kelvin: u32,
) -> crate::error::Result<()> {
if *id == self.ok_device {
Ok(())
} else {
Err(GoveeError::DiscoveryTimeout)
}
}
fn backend_type(&self) -> BackendType {
BackendType::Cloud
}
}
let state = make_state(true, 50, 0, 255, 0);
let mut groups = HashMap::new();
groups.insert(
"all".to_string(),
vec!["Test Light".to_string(), "Failing Light".to_string()],
);
let config = Config::new(
Some("test-key".into()),
BackendPreference::CloudOnly,
60,
HashMap::new(),
groups,
HashMap::new(),
)
.unwrap();
let cloud = Arc::new(SelectiveBackend {
ok_device: DeviceId::new("AA:BB:CC:DD:EE:01").unwrap(),
state,
}) as Arc<dyn GoveeBackend>;
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), None)
.await
.unwrap();
let result = registry.group_set_power("all", true).await;
assert!(result.is_err());
match result.unwrap_err() {
GoveeError::PartialFailure {
succeeded, failed, ..
} => {
assert_eq!(succeeded.len(), 1);
assert_eq!(failed.len(), 1);
assert_eq!(succeeded[0], DeviceId::new("AA:BB:CC:DD:EE:01").unwrap());
assert_eq!(failed[0].0, DeviceId::new("AA:BB:CC:DD:EE:02").unwrap());
}
other => panic!("expected PartialFailure, got {:?}", other),
}
}
use crate::config::SceneConfig;
use crate::scene::SceneTarget;
fn mock_with_two_devices_and_state(state: DeviceState) -> Arc<dyn GoveeBackend> {
Arc::new(
MockBackend::new()
.with_devices(vec![
make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Kitchen Light",
BackendType::Cloud,
),
make_device(
"AA:BB:CC:DD:EE:02",
"H6078",
"Bedroom Light",
BackendType::Cloud,
),
])
.with_state(state)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>
}
#[tokio::test]
async fn apply_scene_single_device_rgb() {
let state = make_state(true, 50, 0, 0, 0);
let (cloud, id) = mock_with_device_and_state("AA:BB:CC:DD:EE:01", state);
let registry = DeviceRegistry::start_with_backends(default_config(), Some(cloud), None)
.await
.unwrap();
registry
.apply_scene("night", SceneTarget::Device(id.clone()))
.await
.unwrap();
let cached = registry.get_state(&id).await.unwrap();
assert_eq!(cached.brightness, 10);
assert_eq!(cached.color, Color::new(255, 0, 0));
assert_eq!(cached.color_temp_kelvin, None);
}
#[tokio::test]
async fn apply_scene_single_device_color_temp() {
let state = make_state(true, 50, 0, 0, 0);
let (cloud, id) = mock_with_device_and_state("AA:BB:CC:DD:EE:01", state);
let registry = DeviceRegistry::start_with_backends(default_config(), Some(cloud), None)
.await
.unwrap();
registry
.apply_scene("warm", SceneTarget::Device(id.clone()))
.await
.unwrap();
let cached = registry.get_state(&id).await.unwrap();
assert_eq!(cached.brightness, 40);
assert_eq!(cached.color_temp_kelvin, Some(2700));
}
#[tokio::test]
async fn apply_scene_to_group() {
let state = make_state(true, 50, 0, 0, 0);
let cloud = mock_with_two_devices_and_state(state);
let mut groups = HashMap::new();
groups.insert(
"all".to_string(),
vec!["Kitchen Light".to_string(), "Bedroom Light".to_string()],
);
let config = config_with_groups(groups);
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), None)
.await
.unwrap();
registry
.apply_scene("night", SceneTarget::Group("all".to_string()))
.await
.unwrap();
let id1 = DeviceId::new("AA:BB:CC:DD:EE:01").unwrap();
let id2 = DeviceId::new("AA:BB:CC:DD:EE:02").unwrap();
let cached1 = registry.get_state(&id1).await.unwrap();
assert_eq!(cached1.brightness, 10);
assert_eq!(cached1.color, Color::new(255, 0, 0));
let cached2 = registry.get_state(&id2).await.unwrap();
assert_eq!(cached2.brightness, 10);
assert_eq!(cached2.color, Color::new(255, 0, 0));
}
#[tokio::test]
async fn apply_scene_to_all_devices() {
let state = make_state(true, 50, 0, 0, 0);
let cloud = mock_with_two_devices_and_state(state);
let registry = DeviceRegistry::start_with_backends(default_config(), Some(cloud), None)
.await
.unwrap();
registry
.apply_scene("focus", SceneTarget::All)
.await
.unwrap();
let id1 = DeviceId::new("AA:BB:CC:DD:EE:01").unwrap();
let id2 = DeviceId::new("AA:BB:CC:DD:EE:02").unwrap();
let cached1 = registry.get_state(&id1).await.unwrap();
assert_eq!(cached1.brightness, 80);
assert_eq!(cached1.color_temp_kelvin, Some(5500));
let cached2 = registry.get_state(&id2).await.unwrap();
assert_eq!(cached2.brightness, 80);
assert_eq!(cached2.color_temp_kelvin, Some(5500));
}
#[tokio::test]
async fn apply_scene_with_device_name() {
let state = make_state(true, 50, 0, 0, 0);
let cloud = mock_with_two_devices_and_state(state);
let registry = DeviceRegistry::start_with_backends(default_config(), Some(cloud), None)
.await
.unwrap();
registry
.apply_scene(
"night",
SceneTarget::DeviceName("Kitchen Light".to_string()),
)
.await
.unwrap();
let id = DeviceId::new("AA:BB:CC:DD:EE:01").unwrap();
let cached = registry.get_state(&id).await.unwrap();
assert_eq!(cached.brightness, 10);
assert_eq!(cached.color, Color::new(255, 0, 0));
}
#[tokio::test]
async fn apply_scene_unknown_scene_returns_error() {
let registry = DeviceRegistry::start_with_backends(default_config(), None, None)
.await
.unwrap();
let err = registry
.apply_scene("nonexistent", SceneTarget::All)
.await
.unwrap_err();
assert!(matches!(err, GoveeError::DeviceNotFound(_)));
}
#[tokio::test]
async fn apply_scene_user_defined_rgb() {
let state = make_state(true, 50, 0, 0, 0);
let (cloud, id) = mock_with_device_and_state("AA:BB:CC:DD:EE:01", state);
let mut scenes = HashMap::new();
scenes.insert(
"custom".to_string(),
SceneConfig {
brightness: 75,
color: Some(Color::new(0, 128, 255)),
color_temp: None,
},
);
let config = Config::new(
None,
BackendPreference::Auto,
60,
HashMap::new(),
HashMap::new(),
scenes,
)
.unwrap();
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), None)
.await
.unwrap();
registry
.apply_scene("custom", SceneTarget::Device(id.clone()))
.await
.unwrap();
let cached = registry.get_state(&id).await.unwrap();
assert_eq!(cached.brightness, 75);
assert_eq!(cached.color, Color::new(0, 128, 255));
}
#[tokio::test]
async fn apply_scene_user_defined_color_temp() {
let state = make_state(true, 50, 0, 0, 0);
let (cloud, id) = mock_with_device_and_state("AA:BB:CC:DD:EE:01", state);
let mut scenes = HashMap::new();
scenes.insert(
"daylight".to_string(),
SceneConfig {
brightness: 100,
color: None,
color_temp: Some(6500),
},
);
let config = Config::new(
None,
BackendPreference::Auto,
60,
HashMap::new(),
HashMap::new(),
scenes,
)
.unwrap();
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), None)
.await
.unwrap();
registry
.apply_scene("daylight", SceneTarget::Device(id.clone()))
.await
.unwrap();
let cached = registry.get_state(&id).await.unwrap();
assert_eq!(cached.brightness, 100);
assert_eq!(cached.color_temp_kelvin, Some(6500));
}
#[tokio::test]
async fn apply_scene_partial_failure() {
use async_trait::async_trait;
struct SelectiveBackend {
ok_device: DeviceId,
state: DeviceState,
}
#[async_trait]
impl GoveeBackend for SelectiveBackend {
async fn list_devices(&self) -> crate::error::Result<Vec<Device>> {
Ok(vec![
Device {
id: self.ok_device.clone(),
model: "H6076".into(),
name: "Test Light".into(),
alias: None,
backend: BackendType::Cloud,
},
Device {
id: DeviceId::new("AA:BB:CC:DD:EE:02").unwrap(),
model: "H6078".into(),
name: "Failing Light".into(),
alias: None,
backend: BackendType::Cloud,
},
])
}
async fn get_state(&self, id: &DeviceId) -> crate::error::Result<DeviceState> {
if *id == self.ok_device {
Ok(self.state.clone())
} else {
Err(GoveeError::DiscoveryTimeout)
}
}
async fn set_power(&self, id: &DeviceId, _on: bool) -> crate::error::Result<()> {
if *id == self.ok_device {
Ok(())
} else {
Err(GoveeError::DiscoveryTimeout)
}
}
async fn set_brightness(&self, id: &DeviceId, _value: u8) -> crate::error::Result<()> {
if *id == self.ok_device {
Ok(())
} else {
Err(GoveeError::DiscoveryTimeout)
}
}
async fn set_color(&self, id: &DeviceId, _color: Color) -> crate::error::Result<()> {
if *id == self.ok_device {
Ok(())
} else {
Err(GoveeError::DiscoveryTimeout)
}
}
async fn set_color_temp(
&self,
id: &DeviceId,
_kelvin: u32,
) -> crate::error::Result<()> {
if *id == self.ok_device {
Ok(())
} else {
Err(GoveeError::DiscoveryTimeout)
}
}
fn backend_type(&self) -> BackendType {
BackendType::Cloud
}
}
let state = make_state(true, 50, 0, 255, 0);
let mut groups = HashMap::new();
groups.insert(
"all".to_string(),
vec!["Test Light".to_string(), "Failing Light".to_string()],
);
let config = Config::new(
Some("test-key".into()),
BackendPreference::CloudOnly,
60,
HashMap::new(),
groups,
HashMap::new(),
)
.unwrap();
let cloud = Arc::new(SelectiveBackend {
ok_device: DeviceId::new("AA:BB:CC:DD:EE:01").unwrap(),
state,
}) as Arc<dyn GoveeBackend>;
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), None)
.await
.unwrap();
let result = registry
.apply_scene("warm", SceneTarget::Group("all".into()))
.await;
assert!(result.is_err());
match result.unwrap_err() {
GoveeError::PartialFailure {
succeeded, failed, ..
} => {
assert_eq!(succeeded.len(), 1);
assert_eq!(failed.len(), 1);
assert_eq!(succeeded[0], DeviceId::new("AA:BB:CC:DD:EE:01").unwrap());
assert_eq!(failed[0].0, DeviceId::new("AA:BB:CC:DD:EE:02").unwrap());
}
other => panic!("expected PartialFailure, got {:?}", other),
}
}
#[tokio::test]
async fn integration_merge_and_resolve() {
let mac = "AA:BB:CC:DD:EE:FF";
let cloud_devices = vec![
make_device(mac, "H6076", "Kitchen Light", BackendType::Cloud),
make_device(
"11:22:33:44:55:66",
"H6078",
"Bedroom Strip",
BackendType::Cloud,
),
];
let local_devices = vec![make_device(mac, "H6076", "H6076_AABB", BackendType::Local)];
let cloud = Arc::new(
MockBackend::new()
.with_devices(cloud_devices)
.with_state(
DeviceState::new(true, 80, Color::new(255, 200, 100), None, false).unwrap(),
)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let local = Arc::new(
MockBackend::new()
.with_devices(local_devices)
.with_state(
DeviceState::new(true, 80, Color::new(255, 200, 100), None, false).unwrap(),
)
.with_backend_type(BackendType::Local),
) as Arc<dyn GoveeBackend>;
let registry =
DeviceRegistry::start_with_backends(default_config(), Some(cloud), Some(local))
.await
.unwrap();
assert_eq!(registry.devices().len(), 2);
let resolved = registry.resolve("Kitchen Light").unwrap();
assert_eq!(resolved, DeviceId::new(mac).unwrap());
let backend = registry.backend_for(&resolved).unwrap();
assert_eq!(backend.backend_type(), BackendType::Local);
let cloud_only = registry.resolve("Bedroom Strip").unwrap();
assert_eq!(cloud_only, DeviceId::new("11:22:33:44:55:66").unwrap());
let cloud_backend = registry.backend_for(&cloud_only).unwrap();
assert_eq!(cloud_backend.backend_type(), BackendType::Cloud);
let state = registry.get_state(&resolved).await.unwrap();
assert_eq!(state.brightness, 80);
}
#[tokio::test]
async fn integration_scene_apply_to_group() {
let devices = vec![
make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Living Room 1",
BackendType::Cloud,
),
make_device(
"AA:BB:CC:DD:EE:02",
"H6078",
"Living Room 2",
BackendType::Cloud,
),
];
let state = DeviceState::new(true, 50, Color::new(0, 0, 0), Some(5500), false).unwrap();
let cloud = Arc::new(
MockBackend::new()
.with_devices(devices)
.with_state(state)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let mut groups = HashMap::new();
groups.insert(
"living-room".to_string(),
vec!["Living Room 1".to_string(), "Living Room 2".to_string()],
);
let config = Config::new(
Some("test-key".into()),
BackendPreference::Auto,
60,
HashMap::new(),
groups,
HashMap::new(),
)
.unwrap();
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), None)
.await
.unwrap();
let members = registry.resolve_group("living-room").unwrap();
assert_eq!(members.len(), 2);
let result = registry
.apply_scene("warm", SceneTarget::Group("living-room".to_string()))
.await;
assert!(result.is_ok(), "apply_scene failed: {:?}", result.err());
let state1 = registry
.get_state(&DeviceId::new("AA:BB:CC:DD:EE:01").unwrap())
.await
.unwrap();
assert_eq!(state1.brightness, 40);
let state2 = registry
.get_state(&DeviceId::new("AA:BB:CC:DD:EE:02").unwrap())
.await
.unwrap();
assert_eq!(state2.brightness, 40);
}
#[tokio::test]
async fn integration_alias_resolution() {
let devices = vec![make_device(
"AA:BB:CC:DD:EE:FF",
"H6076",
"Kitchen Light",
BackendType::Cloud,
)];
let state = DeviceState::new(true, 60, Color::new(255, 255, 255), None, false).unwrap();
let cloud = Arc::new(
MockBackend::new()
.with_devices(devices)
.with_state(state)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let mut aliases = HashMap::new();
aliases.insert("kitchen".to_string(), "Kitchen Light".to_string());
let config = Config::new(
Some("test-key".into()),
BackendPreference::Auto,
60,
aliases,
HashMap::new(),
HashMap::new(),
)
.unwrap();
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), None)
.await
.unwrap();
let id = registry.resolve("kitchen").unwrap();
assert_eq!(id, DeviceId::new("AA:BB:CC:DD:EE:FF").unwrap());
let state = registry.get_state(&id).await.unwrap();
assert_eq!(state.brightness, 60);
assert!(state.on);
let result = registry.set_brightness(&id, 80).await;
assert!(result.is_ok());
let updated = registry.get_state(&id).await.unwrap();
assert_eq!(updated.brightness, 80);
}
#[tokio::test]
async fn integration_backend_modes() {
let cloud_devices = vec![
make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Cloud Only Device",
BackendType::Cloud,
),
make_device(
"AA:BB:CC:DD:EE:02",
"H6078",
"Both Device",
BackendType::Cloud,
),
];
let local_devices = vec![make_device(
"AA:BB:CC:DD:EE:02",
"H6078",
"H6078_LAN",
BackendType::Local,
)];
let cloud = Arc::new(
MockBackend::new()
.with_devices(cloud_devices.clone())
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let local = Arc::new(
MockBackend::new()
.with_devices(local_devices.clone())
.with_backend_type(BackendType::Local),
) as Arc<dyn GoveeBackend>;
let auto_config = Config::new(
Some("key".into()),
BackendPreference::Auto,
60,
HashMap::new(),
HashMap::new(),
HashMap::new(),
)
.unwrap();
let auto_registry =
DeviceRegistry::start_with_backends(auto_config, Some(cloud), Some(local))
.await
.unwrap();
assert_eq!(auto_registry.devices().len(), 2);
let both_id = DeviceId::new("AA:BB:CC:DD:EE:02").unwrap();
let auto_backend = auto_registry.backend_for(&both_id).unwrap();
assert_eq!(auto_backend.backend_type(), BackendType::Local);
let cloud = Arc::new(
MockBackend::new()
.with_devices(cloud_devices.clone())
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let local = Arc::new(
MockBackend::new()
.with_devices(local_devices.clone())
.with_backend_type(BackendType::Local),
) as Arc<dyn GoveeBackend>;
let cloud_config = Config::new(
Some("key".into()),
BackendPreference::CloudOnly,
60,
HashMap::new(),
HashMap::new(),
HashMap::new(),
)
.unwrap();
let cloud_registry =
DeviceRegistry::start_with_backends(cloud_config, Some(cloud), Some(local))
.await
.unwrap();
assert_eq!(cloud_registry.devices().len(), 2);
let cloud_backend = cloud_registry.backend_for(&both_id).unwrap();
assert_eq!(cloud_backend.backend_type(), BackendType::Cloud);
let cloud = Arc::new(
MockBackend::new()
.with_devices(cloud_devices)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let local = Arc::new(
MockBackend::new()
.with_devices(local_devices)
.with_backend_type(BackendType::Local),
) as Arc<dyn GoveeBackend>;
let local_config = Config::new(
None,
BackendPreference::LocalOnly,
60,
HashMap::new(),
HashMap::new(),
HashMap::new(),
)
.unwrap();
let local_registry =
DeviceRegistry::start_with_backends(local_config, Some(cloud), Some(local))
.await
.unwrap();
assert_eq!(local_registry.devices().len(), 1);
assert_eq!(local_registry.devices()[0].id, both_id);
let local_backend = local_registry.backend_for(&both_id).unwrap();
assert_eq!(local_backend.backend_type(), BackendType::Local);
}
#[tokio::test]
async fn auto_mode_fallback_on_primary_failure() {
let mac = "AA:BB:CC:DD:EE:FF";
let device_cloud = make_device(mac, "H6076", "Kitchen Light", BackendType::Cloud);
let device_local = make_device(mac, "H6076", "H6076_AABB", BackendType::Local);
let cloud = Arc::new(
MockBackend::new()
.with_devices(vec![device_cloud])
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let local = Arc::new(
MockBackend::new()
.with_devices(vec![device_local])
.with_backend_type(BackendType::Local)
.with_error(|| GoveeError::BackendUnavailable("LAN timeout".into())),
) as Arc<dyn GoveeBackend>;
let registry = DeviceRegistry::start_with_backends(
default_config(), Some(cloud),
Some(local),
)
.await
.unwrap();
let id = DeviceId::new(mac).unwrap();
registry.set_power(&id, true).await.unwrap();
registry.set_brightness(&id, 50).await.unwrap();
registry
.set_color(&id, Color::new(255, 0, 0))
.await
.unwrap();
registry.set_color_temp(&id, 4000).await.unwrap();
}
#[tokio::test]
async fn cloud_only_no_fallback_on_failure() {
let mac = "AA:BB:CC:DD:EE:FF";
let device_cloud = make_device(mac, "H6076", "Kitchen Light", BackendType::Cloud);
let cloud = Arc::new(
MockBackend::new()
.with_devices(vec![device_cloud])
.with_backend_type(BackendType::Cloud)
.with_error(|| GoveeError::BackendUnavailable("cloud timeout".into())),
) as Arc<dyn GoveeBackend>;
let config = Config::new(
Some("test-key".into()),
BackendPreference::CloudOnly,
60,
HashMap::new(),
HashMap::new(),
HashMap::new(),
)
.unwrap();
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), None)
.await
.unwrap();
let id = DeviceId::new(mac).unwrap();
let result = registry.set_power(&id, true).await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
GoveeError::BackendUnavailable(_)
));
}
#[tokio::test]
async fn local_only_resolve_cloud_device_returns_not_found() {
let cloud_devices = vec![make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Cloud Light",
BackendType::Cloud,
)];
let local_devices = vec![make_device(
"AA:BB:CC:DD:EE:02",
"H6078",
"Local Light",
BackendType::Local,
)];
let cloud = Arc::new(
MockBackend::new()
.with_devices(cloud_devices)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let local = Arc::new(
MockBackend::new()
.with_devices(local_devices)
.with_backend_type(BackendType::Local),
) as Arc<dyn GoveeBackend>;
let config = Config::new(
None,
BackendPreference::LocalOnly,
60,
HashMap::new(),
HashMap::new(),
HashMap::new(),
)
.unwrap();
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), Some(local))
.await
.unwrap();
let id = registry.resolve("Local Light").unwrap();
assert_eq!(id, DeviceId::new("AA:BB:CC:DD:EE:02").unwrap());
let err = registry.resolve("Cloud Light").unwrap_err();
assert!(matches!(err, GoveeError::DeviceNotFound(_)));
}
#[tokio::test]
async fn local_only_group_excludes_pruned_cloud_device() {
let cloud_devices = vec![make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Cloud Light",
BackendType::Cloud,
)];
let local_devices = vec![make_device(
"AA:BB:CC:DD:EE:02",
"H6078",
"Local Light",
BackendType::Local,
)];
let cloud = Arc::new(
MockBackend::new()
.with_devices(cloud_devices)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let local = Arc::new(
MockBackend::new()
.with_devices(local_devices)
.with_backend_type(BackendType::Local),
) as Arc<dyn GoveeBackend>;
let mut groups = HashMap::new();
groups.insert(
"all".to_string(),
vec!["Cloud Light".to_string(), "Local Light".to_string()],
);
let config = Config::new(
None,
BackendPreference::LocalOnly,
60,
HashMap::new(),
groups,
HashMap::new(),
)
.unwrap();
let registry = DeviceRegistry::start_with_backends(config, Some(cloud), Some(local))
.await
.unwrap();
let members = registry.resolve_group("all").unwrap();
assert_eq!(members.len(), 1);
assert_eq!(members[0], DeviceId::new("AA:BB:CC:DD:EE:02").unwrap());
}
#[tokio::test]
async fn auto_mode_cloud_device_is_resolvable() {
let cloud_devices = vec![
make_device(
"AA:BB:CC:DD:EE:01",
"H6076",
"Cloud Light",
BackendType::Cloud,
),
make_device(
"AA:BB:CC:DD:EE:02",
"H6078",
"Both Light",
BackendType::Cloud,
),
];
let local_devices = vec![make_device(
"AA:BB:CC:DD:EE:02",
"H6078",
"H6078_X",
BackendType::Local,
)];
let cloud = Arc::new(
MockBackend::new()
.with_devices(cloud_devices)
.with_backend_type(BackendType::Cloud),
) as Arc<dyn GoveeBackend>;
let local = Arc::new(
MockBackend::new()
.with_devices(local_devices)
.with_backend_type(BackendType::Local),
) as Arc<dyn GoveeBackend>;
let registry =
DeviceRegistry::start_with_backends(default_config(), Some(cloud), Some(local))
.await
.unwrap();
let id = registry.resolve("Cloud Light").unwrap();
assert_eq!(id, DeviceId::new("AA:BB:CC:DD:EE:01").unwrap());
let id = registry.resolve("Both Light").unwrap();
assert_eq!(id, DeviceId::new("AA:BB:CC:DD:EE:02").unwrap());
}
}