Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions crates/hypercolor-core/src/device/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ pub struct TrackedDevice {
/// Full device metadata.
pub info: DeviceInfo,

/// Latest driver-provided name before the user override is applied.
observed_name: String,

/// Current lifecycle state.
pub state: DeviceState,

Expand All @@ -40,6 +43,16 @@ pub struct TrackedDevice {
pub revision: u64,
}

impl TrackedDevice {
/// Device metadata without the user-provided name override.
#[must_use]
pub fn observed_info(&self) -> DeviceInfo {
let mut info = self.info.clone();
info.name.clone_from(&self.observed_name);
info
}
}

// ── DeviceRegistry ───────────────────────────────────────────────────────

/// Thread-safe registry for tracking all known devices.
Expand Down Expand Up @@ -217,9 +230,11 @@ impl DeviceRegistry {
// Keep the canonical registry ID stable across rediscovery.
updated_info.id = existing_id;
preserve_resolved_device_shape(&mut updated_info, &entry.info);
let observed_name = updated_info.name.clone();
apply_user_settings_to_info(&mut updated_info, &entry.user_settings);
let entry_changed =
entry.info != updated_info || entry.connect_behavior != connect_behavior;
let entry_changed = entry.info != updated_info
|| entry.observed_name != observed_name
|| entry.connect_behavior != connect_behavior;
let fingerprint_changed =
inner.id_to_fingerprint.get(&existing_id) != Some(&fingerprint);
let metadata_changed = !metadata.is_empty()
Expand All @@ -236,6 +251,7 @@ impl DeviceRegistry {
.get_mut(&existing_id)
.expect("existing device was resolved above");
entry.info = updated_info;
entry.observed_name = observed_name;
entry.connect_behavior = connect_behavior;
bump_device_revision(entry);
}
Expand Down Expand Up @@ -280,6 +296,7 @@ impl DeviceRegistry {
let mut updated_info = info;
updated_info.id = existing_id;
preserve_resolved_device_shape(&mut updated_info, &entry.info);
entry.observed_name.clone_from(&updated_info.name);
apply_user_settings_to_info(&mut updated_info, &entry.user_settings);
debug!(
device_id = %existing_id,
Expand Down Expand Up @@ -319,6 +336,7 @@ impl DeviceRegistry {

let name = tracked_info.name.clone();
let tracked = TrackedDevice {
observed_name: name.clone(),
info: tracked_info,
state: DeviceState::Known,
connect_behavior,
Expand Down Expand Up @@ -424,6 +442,7 @@ impl DeviceRegistry {

let mut updated_info = info;
updated_info.id = *id;
entry.observed_name.clone_from(&updated_info.name);
apply_user_settings_to_info(&mut updated_info, &entry.user_settings);
entry.info = updated_info;
bump_device_revision(entry);
Expand Down Expand Up @@ -456,6 +475,7 @@ impl DeviceRegistry {
return None;
}
let mut info = discovered.info;
entry.observed_name.clone_from(&info.name);
apply_user_settings_to_info(&mut info, &entry.user_settings);
entry.info = info;
entry.connect_behavior = discovered.connect_behavior;
Expand Down Expand Up @@ -521,6 +541,7 @@ impl DeviceRegistry {
let entry = inner.devices.get_mut(id)?;

let mut updated_info = entry.info.clone();
updated_info.name.clone_from(&entry.observed_name);
apply_user_settings_to_info(&mut updated_info, &settings);
if entry.user_settings == settings && entry.info == updated_info {
return Some(entry.clone());
Expand Down Expand Up @@ -735,6 +756,7 @@ impl DeviceRegistry {
.expect("device presence was checked above");
if let Some(settings) = inherited_settings {
entry.user_settings = settings;
entry.info.name.clone_from(&entry.observed_name);
apply_user_settings_to_info(&mut entry.info, &entry.user_settings);
}
bump_device_revision(entry);
Expand Down
27 changes: 27 additions & 0 deletions crates/hypercolor-core/src/scene/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1179,6 +1179,33 @@ impl SceneManager {
})
}

/// Replace only a zone's authored layer stack, preserving its other fields.
///
/// # Errors
///
/// Refuses missing targets, duplicate identities or invalid layer values
/// before changing the stack.
pub fn replace_zone_layer_stack(
&mut self,
scene_id: SceneId,
zone_id: ZoneId,
layers: Vec<SceneLayer>,
) -> Result<(&Zone, u64), LayerMutationError> {
let mut ids = HashSet::new();
for layer in &layers {
if !ids.insert(layer.id) {
return Err(LayerMutationError::DuplicateLayer { layer_id: layer.id });
}
layer
.validate()
.map_err(|errors| LayerMutationError::InvalidLayer { errors })?;
}
self.mutate_zone_layers(scene_id, zone_id, None, |zone| {
zone.layers = layers;
Ok(())
})
}

pub fn remove_zone_layer(
&mut self,
scene_id: SceneId,
Expand Down
172 changes: 172 additions & 0 deletions crates/hypercolor-core/tests/device_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2055,3 +2055,175 @@ async fn orchestrator_reappeared_device_keeps_stable_id_when_scanner_emits_new_i
.expect("stable registry entry should remain");
assert_eq!(tracked.info.id, existing_id);
}

#[tokio::test]
async fn registry_name_clear_restores_latest_observation_and_preserves_settings() {
let registry = DeviceRegistry::new();
let fingerprint = DeviceFingerprint::from_persisted("bridge:name-reset".to_owned());
let id = registry
.add_with_fingerprint(mock_device_info("Original"), fingerprint.clone())
.await;
registry
.update_user_settings(
&id,
Some("Custom".to_owned()),
Some(false),
Some(0.37),
None,
)
.await;
let before = registry.get(&id).await.expect("registered");
assert_eq!(
registry
.add_with_fingerprint(mock_device_info("Latest"), fingerprint)
.await,
id
);
let observed = registry.get(&id).await.expect("rediscovered");
assert_eq!(observed.info.name, "Custom");
assert!(observed.revision > before.revision);
let settings = DeviceUserSettings {
name: None,
..observed.user_settings
};
let cleared = registry
.replace_user_settings(&id, settings.clone())
.await
.expect("cleared");
assert_eq!(cleared.info.name, "Latest");
assert!(!cleared.user_settings.enabled);
assert_eq!(cleared.user_settings.brightness, 0.37);
assert_eq!(registry.list().await[0].info.name, "Latest");
let generation = registry.generation();
let replay = registry
.replace_user_settings(&id, settings)
.await
.expect("replayed");
assert_eq!(replay.revision, cleared.revision);
assert_eq!(registry.generation(), generation);
}

#[tokio::test]
async fn registry_metadata_refresh_keeps_latest_name_behind_override() {
let registry = DeviceRegistry::new();
let fingerprint = DeviceFingerprint::from_persisted("bridge:name-refresh".to_owned());
let id = registry
.add_with_fingerprint(mock_device_info("Original"), fingerprint.clone())
.await;
registry
.update_user_settings(&id, Some("Custom".to_owned()), None, None, None)
.await;
registry
.update_info(&id, mock_device_info("Connected name"))
.await
.expect("metadata update");
let settings = registry.get(&id).await.expect("registered").user_settings;
assert_eq!(
registry
.replace_user_settings(
&id,
DeviceUserSettings {
name: None,
..settings
}
)
.await
.expect("clear")
.info
.name,
"Connected name"
);
registry
.update_user_settings(&id, Some("Custom again".to_owned()), None, None, None)
.await;
let mut observation = DiscoveredDevice {
info: DeviceInfo {
id,
..mock_device_info("Refreshed name")
},
fingerprint,
connect_behavior: DiscoveryConnectBehavior::AutoConnect,
metadata: HashMap::new(),
claim: None,
};
registry
.refresh_discovered(&id, observation.clone())
.await
.expect("refresh");
let tracked = registry.get(&id).await.expect("refreshed device");
assert_eq!(tracked.info.name, "Custom again");
assert_eq!(tracked.observed_info().name, "Refreshed name");
let mut metadata_only = observation.clone();
metadata_only.info = tracked.observed_info();
metadata_only
.metadata
.insert("status".to_owned(), "ready".to_owned());
registry
.refresh_discovered(&id, metadata_only)
.await
.expect("metadata-only refresh");
observation.info.name = "Rejected name".to_owned();
observation.info.id = DeviceId::new();
assert!(
registry
.refresh_discovered(&id, observation)
.await
.is_none()
);
let settings = registry.get(&id).await.expect("registered").user_settings;
assert_eq!(
registry
.replace_user_settings(
&id,
DeviceUserSettings {
name: None,
..settings
}
)
.await
.expect("clear refreshed name")
.info
.name,
"Refreshed name"
);
}

#[tokio::test]
async fn registry_name_clear_after_smbus_remap_uses_new_observed_name() {
let registry = DeviceRegistry::new();
let id = registry
.add_with_fingerprint_and_metadata(
asus_dram_device_info(0x71),
DeviceFingerprint::from_persisted("smbus:/dev/i2c-9:71".to_owned()),
asus_dram_metadata(0x71),
)
.await;
registry.set_state(&id, DeviceState::Connected).await;
registry
.update_user_settings(&id, Some("My RAM".to_owned()), None, None, None)
.await;
let remapped = registry
.add_with_fingerprint_and_metadata(
asus_dram_device_info(0x73),
DeviceFingerprint::from_persisted("smbus:/dev/i2c-9:73".to_owned()),
asus_dram_metadata(0x73),
)
.await;
assert_eq!(remapped, id);
let settings = registry.get(&id).await.expect("remapped").user_settings;
assert_eq!(
registry
.replace_user_settings(
&id,
DeviceUserSettings {
name: None,
..settings
}
)
.await
.expect("clear")
.info
.name,
"ASUS Aura DRAM (SMBus 0x73)"
);
}
2 changes: 1 addition & 1 deletion crates/hypercolor-daemon/src/discovery/device_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ pub(super) async fn refresh_connected_device_info(
.fingerprint_for_id(&device_id)
.await
.context("connected device has no registered fingerprint")?;
let mut info = maybe_info.unwrap_or(tracked.info);
let mut info = maybe_info.unwrap_or_else(|| tracked.observed_info());
info.id = device_id;
runtime
.device_registry
Expand Down
6 changes: 3 additions & 3 deletions crates/hypercolor-daemon/src/domain/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ pub enum CommitDurability {
/// The payload replaced the destination and its durability barrier
/// completed.
Written,
/// A newer admitted generation won the destination first. The newer
/// payload is authoritative and already contains this commit's
/// changes, so this commit's own bytes will never be written.
/// A newer admitted generation won the destination first. This commit's
/// own bytes will never be written; the newer payload may have overwritten
/// its fields and is not proof that this commit's changes became durable.
Superseded,
/// The write did not prove durable on this attempt. The payload
/// stays the destination's newest admitted intent and the retry
Expand Down
Loading