Final Release of Version 1.0

This commit is contained in:
Breadway 2026-05-13 22:01:42 +08:00
parent 434fe1721c
commit 425b746780
34 changed files with 3129 additions and 567 deletions

View file

@ -44,7 +44,11 @@ impl EventNormalizer {
}
fn normalize_udev(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let action = raw.payload.get("action").and_then(Value::as_str).unwrap_or("change");
let action = raw
.payload
.get("action")
.and_then(Value::as_str)
.unwrap_or("change");
// "bind" is the kernel attaching a driver to an interface — not a meaningful
// device state change for automation purposes.
@ -52,11 +56,31 @@ impl EventNormalizer {
return vec![];
}
let name = raw.payload.get("name").and_then(Value::as_str).unwrap_or("unknown");
let vendor = raw.payload.get("id_vendor").and_then(Value::as_str).unwrap_or_default();
let vendor_id = raw.payload.get("vendor_id").and_then(Value::as_str).unwrap_or_default();
let product_id = raw.payload.get("product_id").and_then(Value::as_str).unwrap_or_default();
let subsystem = raw.payload.get("subsystem").and_then(Value::as_str).unwrap_or_default();
let name = raw
.payload
.get("name")
.and_then(Value::as_str)
.unwrap_or("unknown");
let vendor = raw
.payload
.get("id_vendor")
.and_then(Value::as_str)
.unwrap_or_default();
let vendor_id = raw
.payload
.get("vendor_id")
.and_then(Value::as_str)
.unwrap_or_default();
let product_id = raw
.payload
.get("product_id")
.and_then(Value::as_str)
.unwrap_or_default();
let subsystem = raw
.payload
.get("subsystem")
.and_then(Value::as_str)
.unwrap_or_default();
// Drop anonymous child USB interfaces (e.g. 3-5:1.0, 3-5:1.1) that carry
// no identity information — they are USB protocol artefacts, not devices.
@ -74,7 +98,10 @@ impl EventNormalizer {
_ => "changed",
};
if (verb == "connected" || verb == "disconnected") && !vendor_id.is_empty() && !product_id.is_empty() {
if (verb == "connected" || verb == "disconnected")
&& !vendor_id.is_empty()
&& !product_id.is_empty()
{
let device_key = format!("{}:{}:{}", verb, vendor_id, product_id);
let now = raw.timestamp;
let already_seen = {
@ -89,13 +116,18 @@ impl EventNormalizer {
let mut seen = self.seen_devices.write().unwrap_or_else(|p| p.into_inner());
seen.insert(device_key, now);
// Evict stale entries
let evict_before = now.saturating_sub(self.dedup_window_ms.saturating_mul(EVICT_MULTIPLIER));
let evict_before =
now.saturating_sub(self.dedup_window_ms.saturating_mul(EVICT_MULTIPLIER));
if evict_before > 0 {
seen.retain(|_, &mut last| last >= evict_before);
}
}
let id = raw.payload.get("id").and_then(Value::as_str).unwrap_or("unknown");
let id = raw
.payload
.get("id")
.and_then(Value::as_str)
.unwrap_or("unknown");
// Device name is always "unknown" here; the state engine applies user-defined
// classification rules from devices.lua before dispatching to subscribers.
@ -117,7 +149,11 @@ impl EventNormalizer {
}
fn normalize_hyprland(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let kind = raw.payload.get("kind").and_then(Value::as_str).unwrap_or("unknown");
let kind = raw
.payload
.get("kind")
.and_then(Value::as_str)
.unwrap_or("unknown");
let data = raw
.payload
.get("data")
@ -168,7 +204,7 @@ impl EventNormalizer {
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({
"address": fields.get(0).unwrap_or(&"")
"address": fields.first().unwrap_or(&"")
}),
}]
}
@ -179,7 +215,7 @@ impl EventNormalizer {
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({
"address": fields.get(0).unwrap_or(&""),
"address": fields.first().unwrap_or(&""),
"workspace": fields.get(1).unwrap_or(&""),
"class": fields.get(2).unwrap_or(&""),
"title": fields.get(3).unwrap_or(&""),
@ -192,7 +228,7 @@ impl EventNormalizer {
event: "bread.window.closed".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({ "address": fields.get(0).unwrap_or(&"") }),
data: json!({ "address": fields.first().unwrap_or(&"") }),
}]
}
"movewindow" => {
@ -202,7 +238,7 @@ impl EventNormalizer {
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: json!({
"address": fields.get(0).unwrap_or(&""),
"address": fields.first().unwrap_or(&""),
"workspace": fields.get(1).unwrap_or(&""),
}),
}]
@ -268,7 +304,11 @@ impl EventNormalizer {
}
fn normalize_network(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let online = raw.payload.get("online").and_then(Value::as_bool).unwrap_or(false);
let online = raw
.payload
.get("online")
.and_then(Value::as_bool)
.unwrap_or(false);
let name = if online {
"bread.network.connected"
} else {
@ -310,7 +350,8 @@ impl EventNormalizer {
recent.insert(key.clone(), now);
// Evict stale entries to prevent unbounded growth.
let evict_before = now.saturating_sub(self.dedup_window_ms.saturating_mul(EVICT_MULTIPLIER));
let evict_before =
now.saturating_sub(self.dedup_window_ms.saturating_mul(EVICT_MULTIPLIER));
if evict_before > 0 {
recent.retain(|_, &mut last| last >= evict_before);
}
@ -326,3 +367,403 @@ fn split_hyprland_fields(data: &str) -> Vec<&str> {
data.split(">>").collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn raw(source: AdapterSource, kind: &str, payload: Value, ts: u64) -> RawEvent {
RawEvent {
source,
kind: kind.to_string(),
payload,
timestamp: ts,
}
}
// ─── Udev ─────────────────────────────────────────────────────────────
#[test]
fn udev_add_emits_connected_with_identity_fields() {
let n = EventNormalizer::new(100);
let ev = raw(
AdapterSource::Udev,
"udev",
json!({
"action": "add",
"name": "Logitech Mouse",
"id_vendor": "Logitech",
"vendor_id": "046d",
"product_id": "c52b",
"subsystem": "usb",
"id": "1-1.4",
}),
1000,
);
let out = n.normalize(&ev);
assert_eq!(out.len(), 1);
assert_eq!(out[0].event, "bread.device.connected");
assert_eq!(out[0].data.get("vendor_id").unwrap(), "046d");
assert_eq!(out[0].data.get("product_id").unwrap(), "c52b");
assert_eq!(out[0].data.get("name").unwrap(), "Logitech Mouse");
assert_eq!(out[0].data.get("subsystem").unwrap(), "usb");
assert_eq!(out[0].data.get("device").unwrap(), "unknown");
}
#[test]
fn udev_remove_emits_disconnected() {
let n = EventNormalizer::new(100);
let ev = raw(
AdapterSource::Udev,
"udev",
json!({
"action": "remove",
"name": "Logitech",
"vendor_id": "046d",
"product_id": "c52b",
"subsystem": "usb",
"id": "1-1.4",
}),
1000,
);
let out = n.normalize(&ev);
assert_eq!(out.len(), 1);
assert_eq!(out[0].event, "bread.device.disconnected");
}
#[test]
fn udev_bind_action_is_suppressed() {
let n = EventNormalizer::new(100);
let ev = raw(
AdapterSource::Udev,
"udev",
json!({
"action": "bind",
"name": "x",
"vendor_id": "046d",
"product_id": "c52b",
}),
1000,
);
assert!(n.normalize(&ev).is_empty());
}
#[test]
fn udev_anonymous_child_interface_is_dropped() {
let n = EventNormalizer::new(100);
// No name, no vendor — pure USB protocol artefact.
let ev = raw(
AdapterSource::Udev,
"udev",
json!({
"action": "add",
"id": "3-5:1.0",
}),
1000,
);
assert!(n.normalize(&ev).is_empty());
}
#[test]
fn udev_dedupes_child_nodes_of_same_physical_device() {
let n = EventNormalizer::new(1000);
let mk = |id: &str, ts: u64| {
raw(
AdapterSource::Udev,
"udev",
json!({
"action": "add",
"name": "Hub Device",
"vendor_id": "1d6b",
"product_id": "0002",
"subsystem": "usb",
"id": id,
}),
ts,
)
};
// First child fires
assert_eq!(n.normalize(&mk("usb-1", 1000)).len(), 1);
// Sibling within window is suppressed
assert_eq!(n.normalize(&mk("usb-2", 1050)).len(), 0);
// After the dedup window, a sibling fires again
assert_eq!(n.normalize(&mk("usb-3", 3000)).len(), 1);
}
#[test]
fn udev_disconnect_does_not_share_dedup_with_connect() {
let n = EventNormalizer::new(1000);
let connect = raw(
AdapterSource::Udev,
"udev",
json!({"action": "add", "name": "x", "vendor_id": "1", "product_id": "2", "id": "a"}),
1000,
);
let disconnect = raw(
AdapterSource::Udev,
"udev",
json!({"action": "remove", "name": "x", "vendor_id": "1", "product_id": "2", "id": "a"}),
1100,
);
assert_eq!(n.normalize(&connect).len(), 1);
// Disconnect uses a different verb in the dedup key, so it fires.
assert_eq!(n.normalize(&disconnect).len(), 1);
}
// ─── Hyprland ─────────────────────────────────────────────────────────
#[test]
fn hyprland_workspace_change() {
let n = EventNormalizer::new(0);
let ev = raw(
AdapterSource::Hyprland,
"hypr",
json!({"kind": "workspace", "data": "2"}),
1,
);
let out = n.normalize(&ev);
assert_eq!(out.len(), 1);
assert_eq!(out[0].event, "bread.workspace.changed");
}
#[test]
fn hyprland_active_window_v2_parses_address_from_fields() {
let n = EventNormalizer::new(0);
let ev = raw(
AdapterSource::Hyprland,
"hypr",
json!({"kind": "activewindowv2", "data": "0xdeadbeef"}),
1,
);
let out = n.normalize(&ev);
assert_eq!(out.len(), 1);
assert_eq!(out[0].event, "bread.window.focused");
assert_eq!(out[0].data.get("address").unwrap(), "0xdeadbeef");
}
#[test]
fn hyprland_openwindow_splits_all_fields() {
let n = EventNormalizer::new(0);
let ev = raw(
AdapterSource::Hyprland,
"hypr",
json!({"kind": "openwindow", "data": "0xabc>>2>>firefox>>Mozilla Firefox"}),
1,
);
let out = n.normalize(&ev);
assert_eq!(out.len(), 1);
assert_eq!(out[0].event, "bread.window.opened");
let d = &out[0].data;
assert_eq!(d.get("address").unwrap(), "0xabc");
assert_eq!(d.get("workspace").unwrap(), "2");
assert_eq!(d.get("class").unwrap(), "firefox");
assert_eq!(d.get("title").unwrap(), "Mozilla Firefox");
}
#[test]
fn hyprland_unknown_kind_falls_through_to_generic_event() {
let n = EventNormalizer::new(0);
let ev = raw(
AdapterSource::Hyprland,
"hypr",
json!({"kind": "submap", "data": "resize"}),
1,
);
let out = n.normalize(&ev);
assert_eq!(out.len(), 1);
assert_eq!(out[0].event, "bread.hyprland.event");
}
#[test]
fn hyprland_monitor_lifecycle() {
let n = EventNormalizer::new(0);
let added = n.normalize(&raw(
AdapterSource::Hyprland,
"hypr",
json!({"kind": "monitoradded", "data": "HDMI-A-1"}),
1,
));
let removed = n.normalize(&raw(
AdapterSource::Hyprland,
"hypr",
json!({"kind": "monitorremoved", "data": "HDMI-A-1"}),
2,
));
assert_eq!(added[0].event, "bread.monitor.connected");
assert_eq!(added[0].data.get("name").unwrap(), "HDMI-A-1");
assert_eq!(removed[0].event, "bread.monitor.disconnected");
}
// ─── Power ─────────────────────────────────────────────────────────────
#[test]
fn power_ac_connected_emits_named_event() {
let n = EventNormalizer::new(0);
let out = n.normalize(&raw(
AdapterSource::Power,
"power",
json!({"ac_connected": true}),
1,
));
assert_eq!(out.len(), 1);
assert_eq!(out[0].event, "bread.power.ac.connected");
}
#[test]
fn power_battery_thresholds_select_correct_event() {
let n = EventNormalizer::new(0);
let cases = [
(3, "bread.power.battery.critical"),
(5, "bread.power.battery.critical"),
(8, "bread.power.battery.very_low"),
(10, "bread.power.battery.very_low"),
(15, "bread.power.battery.low"),
(20, "bread.power.battery.low"),
(100, "bread.power.battery.full"),
];
for (level, expected) in cases {
let out = n.normalize(&raw(
AdapterSource::Power,
"power",
json!({"battery_percent": level}),
level * 1000,
));
assert_eq!(
out[0].event, expected,
"level {level} should map to {expected}"
);
}
}
#[test]
fn power_mid_range_battery_emits_generic_changed() {
let n = EventNormalizer::new(0);
let out = n.normalize(&raw(
AdapterSource::Power,
"power",
json!({"battery_percent": 50}),
1,
));
assert_eq!(out.len(), 1);
assert_eq!(out[0].event, "bread.power.changed");
}
#[test]
fn power_ac_and_battery_can_both_fire() {
let n = EventNormalizer::new(0);
let out = n.normalize(&raw(
AdapterSource::Power,
"power",
json!({"ac_connected": false, "battery_percent": 4}),
1,
));
let names: Vec<&str> = out.iter().map(|e| e.event.as_str()).collect();
assert!(names.contains(&"bread.power.ac.disconnected"));
assert!(names.contains(&"bread.power.battery.critical"));
}
// ─── Network ───────────────────────────────────────────────────────────
#[test]
fn network_online_and_offline() {
let n = EventNormalizer::new(0);
let online = n.normalize(&raw(
AdapterSource::Network,
"net",
json!({"online": true}),
1,
));
let offline = n.normalize(&raw(
AdapterSource::Network,
"net",
json!({"online": false}),
2,
));
assert_eq!(online[0].event, "bread.network.connected");
assert_eq!(offline[0].event, "bread.network.disconnected");
}
// ─── System pass-through ───────────────────────────────────────────────
#[test]
fn system_events_pass_through_unchanged() {
let n = EventNormalizer::new(0);
let out = n.normalize(&raw(
AdapterSource::System,
"bread.custom.event",
json!({"foo": "bar"}),
1,
));
assert_eq!(out.len(), 1);
assert_eq!(out[0].event, "bread.custom.event");
assert_eq!(out[0].source, AdapterSource::System);
assert_eq!(out[0].data.get("foo").unwrap(), "bar");
}
// ─── Dedup ─────────────────────────────────────────────────────────────
#[test]
fn dedup_drops_duplicate_within_window() {
let n = EventNormalizer::new(500);
let ev = raw(AdapterSource::Network, "net", json!({"online": true}), 1000);
assert_eq!(n.normalize(&ev).len(), 1);
let dup = raw(AdapterSource::Network, "net", json!({"online": true}), 1200);
assert_eq!(n.normalize(&dup).len(), 0);
}
#[test]
fn dedup_allows_after_window_elapses() {
let n = EventNormalizer::new(500);
let first = raw(AdapterSource::Network, "net", json!({"online": true}), 1000);
assert_eq!(n.normalize(&first).len(), 1);
let later = raw(AdapterSource::Network, "net", json!({"online": true}), 2000);
assert_eq!(n.normalize(&later).len(), 1);
}
#[test]
fn dedup_distinguishes_different_payloads() {
let n = EventNormalizer::new(10_000);
let a = raw(
AdapterSource::Hyprland,
"hypr",
json!({"kind": "workspace", "data": "1"}),
1000,
);
let b = raw(
AdapterSource::Hyprland,
"hypr",
json!({"kind": "workspace", "data": "2"}),
1100,
);
assert_eq!(n.normalize(&a).len(), 1);
// Different payloads = different dedup key
assert_eq!(n.normalize(&b).len(), 1);
}
#[test]
fn dedup_window_of_zero_allows_everything() {
let n = EventNormalizer::new(0);
for _ in 0..3 {
assert_eq!(
n.normalize(&raw(
AdapterSource::Network,
"net",
json!({"online": true}),
1000,
))
.len(),
1
);
}
}
// ─── Helper ────────────────────────────────────────────────────────────
#[test]
fn split_fields_handles_empty_and_single() {
assert!(split_hyprland_fields("").is_empty());
assert_eq!(split_hyprland_fields("only"), vec!["only"]);
assert_eq!(split_hyprland_fields("a>>b>>c"), vec!["a", "b", "c"]);
}
}