feat: enhance device normalization and classification
- Introduced a new mechanism in EventNormalizer to suppress duplicate events from child nodes of the same physical device. - Removed the device classification logic from the normalizer and replaced it with a rule-based system using Lua scripts. - Added support for user-defined device rules in Lua, allowing for flexible device naming based on various conditions. - Updated the state engine to handle device rules and resolve device names before dispatching events. - Modified the installation script to set up default configuration files for the daemon and Lua modules. - Improved the handling of systemd user services to dynamically set the ExecStart path based on the installation directory.
This commit is contained in:
parent
22f591e0e6
commit
434fe1721c
12 changed files with 719 additions and 476 deletions
32
.gitignore
vendored
32
.gitignore
vendored
|
|
@ -1,4 +1,36 @@
|
||||||
|
# Rust build artifacts
|
||||||
target/
|
target/
|
||||||
|
|
||||||
|
# Editor and IDE files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS artifacts
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# Environment and secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.env
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.p12
|
||||||
|
secrets/
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Runtime files
|
||||||
|
*.sock
|
||||||
|
*.pid
|
||||||
|
|
||||||
|
# Internal project docs and spec files kept out of public history
|
||||||
Overview.md
|
Overview.md
|
||||||
DAEMON.md
|
DAEMON.md
|
||||||
LUA_RUNTIME.md
|
LUA_RUNTIME.md
|
||||||
|
|
|
||||||
116
Documentation.md
116
Documentation.md
|
|
@ -471,30 +471,110 @@ monitors.on({
|
||||||
|
|
||||||
### `bread.devices`
|
### `bread.devices`
|
||||||
|
|
||||||
Device connection rules with class-based matching.
|
Device connection rules with name-based matching. This module handles hardware hotplug events from USB devices, monitors, and other peripherals.
|
||||||
|
|
||||||
|
Device names are defined in `~/.config/bread/devices.lua` — the daemon resolves the name before dispatching events, so modules can match on stable user-defined names rather than raw hardware identifiers.
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
local devices = require("bread.devices")
|
local devices = require("bread.devices")
|
||||||
|
|
||||||
-- Register a name pattern → class mapping
|
devices.on({
|
||||||
devices.register("CalDigit", "dock")
|
when = "connected",
|
||||||
devices.register("Keychron", "keyboard")
|
device = "keyboard",
|
||||||
|
run = function(event)
|
||||||
|
bread.exec("xset r rate 200 40")
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
devices.on({
|
devices.on({
|
||||||
when = "connected",
|
when = "connected",
|
||||||
class = "keyboard",
|
device = "dock",
|
||||||
run = function(event)
|
run = "~/.config/bread/scripts/dock-connected.sh"
|
||||||
|
})
|
||||||
|
|
||||||
|
devices.on({
|
||||||
|
when = "disconnected",
|
||||||
|
name = "CalDigit", -- pattern-matched against event.data.name
|
||||||
|
run = function(event)
|
||||||
|
bread.log("Dock disconnected: " .. event.data.name)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Functions
|
||||||
|
|
||||||
|
| Function | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `M.on(opts)` | Register a device rule. See options below. |
|
||||||
|
|
||||||
|
#### Device rule options
|
||||||
|
|
||||||
|
```lua
|
||||||
|
devices.on({
|
||||||
|
when = "connected", -- required: "connected" or "disconnected"
|
||||||
|
device = "keyboard", -- optional: device name from devices.lua
|
||||||
|
name = "Keychron", -- optional: substring matched against device name
|
||||||
|
run = function(event) ... end -- required: function or shell string
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- `when` (required): One of `connected` or `disconnected`.
|
||||||
|
- `device` (optional): Device name as defined in `devices.lua`. If specified, the rule only fires for devices with that name.
|
||||||
|
- `name` (optional): Pattern that must be found in `event.data.name` (case-insensitive substring). Can be combined with `device` (both must match).
|
||||||
|
- `run` (required): Function or shell string to run when the rule matches.
|
||||||
|
|
||||||
|
The callback receives the full device event:
|
||||||
|
```lua
|
||||||
|
{
|
||||||
|
event = "bread.device.dock.connected",
|
||||||
|
data = {
|
||||||
|
id = "/sys/...",
|
||||||
|
device = "dock", -- name resolved from devices.lua
|
||||||
|
name = "CalDigit TS4", -- raw device name from udev
|
||||||
|
subsystem = "usb",
|
||||||
|
vendor_id = "0x35f5",
|
||||||
|
product_id = "0x0104",
|
||||||
|
raw = { ... } -- full udev properties
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Example: Keyboard configuration on connect
|
||||||
|
|
||||||
|
```lua
|
||||||
|
devices.on({
|
||||||
|
when = "connected",
|
||||||
|
device = "keyboard",
|
||||||
|
run = function(event)
|
||||||
|
bread.log("Keyboard connected: " .. event.data.name)
|
||||||
bread.exec("xset r rate 200 40")
|
bread.exec("xset r rate 200 40")
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
| Function | Description |
|
#### Example: Dock-specific setup
|
||||||
|----------|-------------|
|
|
||||||
| `M.on(opts)` | Register a device rule. `opts`: `when`, `class` (optional), `name` (optional pattern), `run` |
|
|
||||||
| `M.register(pattern, class)` | Map a device name pattern to a class string |
|
|
||||||
|
|
||||||
`class` values: `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`.
|
```lua
|
||||||
|
-- devices.lua defines: { device = "dock", vendor_id = "35f5" }
|
||||||
|
|
||||||
|
devices.on({
|
||||||
|
when = "connected",
|
||||||
|
device = "dock",
|
||||||
|
run = function(event)
|
||||||
|
bread.log("Dock connected")
|
||||||
|
bread.exec("~/.config/bread/scripts/dock-connected.sh")
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
devices.on({
|
||||||
|
when = "disconnected",
|
||||||
|
device = "dock",
|
||||||
|
run = function(event)
|
||||||
|
bread.log("Dock disconnected")
|
||||||
|
bread.exec("~/.config/bread/scripts/dock-disconnected.sh")
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
### `bread.workspaces`
|
### `bread.workspaces`
|
||||||
|
|
||||||
|
|
@ -570,12 +650,12 @@ Events are delivered as a `BreadEvent`:
|
||||||
|
|
||||||
| Event | Data |
|
| Event | Data |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
| `bread.device.connected` | `{ id, class, name, subsystem, vendor_id?, product_id? }` |
|
| `bread.device.connected` | `{ id, device, name, vendor, vendor_id, product_id, subsystem, raw }` |
|
||||||
| `bread.device.disconnected` | `{ id, class, name, subsystem, vendor_id?, product_id? }` |
|
| `bread.device.disconnected` | same |
|
||||||
| `bread.device.<class>.connected` | same |
|
| `bread.device.<device>.connected` | `{ id, device }` |
|
||||||
| `bread.device.<class>.disconnected` | same |
|
| `bread.device.<device>.disconnected` | `{ id, device }` |
|
||||||
|
|
||||||
`class`: `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`.
|
`device` is the name resolved from `~/.config/bread/devices.lua`. Devices that match no rule use `"unknown"`. The generic `bread.device.connected` event carries the full payload including `raw` udev properties; the named companion event carries only `id` and `device`.
|
||||||
|
|
||||||
#### Hyprland
|
#### Hyprland
|
||||||
|
|
||||||
|
|
@ -641,7 +721,7 @@ Events are delivered as a `BreadEvent`:
|
||||||
{
|
{
|
||||||
"id": "/sys/...",
|
"id": "/sys/...",
|
||||||
"name": "CalDigit TS4",
|
"name": "CalDigit TS4",
|
||||||
"class": "dock",
|
"device": "dock",
|
||||||
"subsystem": "usb",
|
"subsystem": "usb",
|
||||||
"vendor_id": "0x35f5",
|
"vendor_id": "0x35f5",
|
||||||
"product_id": "0x0104"
|
"product_id": "0x0104"
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,7 @@ bread reload --watch # Watch config dir and reload on changes
|
||||||
# State and events
|
# State and events
|
||||||
bread state # Dump full runtime state as JSON
|
bread state # Dump full runtime state as JSON
|
||||||
bread events # Stream live normalized events
|
bread events # Stream live normalized events
|
||||||
bread events --filter bread.device.* # Stream filtered events
|
bread events bread.device.* # Stream filtered events
|
||||||
bread events --since 60 # Replay events from the last 60 seconds
|
bread events --since 60 # Replay events from the last 60 seconds
|
||||||
bread emit <event> # Manually fire an event (for testing)
|
bread emit <event> # Manually fire an event (for testing)
|
||||||
|
|
||||||
|
|
@ -319,9 +319,8 @@ Events follow the namespace convention `bread.<subsystem>.<noun>.<verb>`.
|
||||||
| `bread.system.startup` | Daemon fully initialized |
|
| `bread.system.startup` | Daemon fully initialized |
|
||||||
| `bread.device.connected` | Any device attached |
|
| `bread.device.connected` | Any device attached |
|
||||||
| `bread.device.disconnected` | Any device removed |
|
| `bread.device.disconnected` | Any device removed |
|
||||||
| `bread.device.dock.connected` | Dock attached |
|
| `bread.device.<device>.connected` | Named device attached (name from `devices.lua`) |
|
||||||
| `bread.device.dock.disconnected` | Dock removed |
|
| `bread.device.<device>.disconnected` | Named device removed |
|
||||||
| `bread.device.keyboard.connected` | Keyboard attached |
|
|
||||||
| `bread.monitor.connected` | Display connected |
|
| `bread.monitor.connected` | Display connected |
|
||||||
| `bread.monitor.disconnected` | Display disconnected |
|
| `bread.monitor.disconnected` | Display disconnected |
|
||||||
| `bread.workspace.changed` | Active workspace changed |
|
| `bread.workspace.changed` | Active workspace changed |
|
||||||
|
|
@ -380,7 +379,7 @@ end)
|
||||||
|
|
||||||
-- Subscribe with a filter predicate
|
-- Subscribe with a filter predicate
|
||||||
bread.filter("bread.device.connected", function(event)
|
bread.filter("bread.device.connected", function(event)
|
||||||
return event.data.class == "keyboard"
|
return event.data.device == "keyboard"
|
||||||
end, function(event)
|
end, function(event)
|
||||||
bread.exec("xset r rate 200 40")
|
bread.exec("xset r rate 200 40")
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,8 @@ enum Commands {
|
||||||
},
|
},
|
||||||
/// Stream live normalized events
|
/// Stream live normalized events
|
||||||
Events {
|
Events {
|
||||||
#[arg(long)]
|
/// Optional glob pattern to filter events (e.g. bread.device.*, bread.**)
|
||||||
filter: Option<String>,
|
pattern: Option<String>,
|
||||||
/// Output raw JSON
|
/// Output raw JSON
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
json: bool,
|
json: bool,
|
||||||
|
|
@ -169,12 +169,12 @@ async fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Commands::Events {
|
Commands::Events {
|
||||||
filter,
|
pattern,
|
||||||
json,
|
json,
|
||||||
fields,
|
fields,
|
||||||
since,
|
since,
|
||||||
} => {
|
} => {
|
||||||
stream_events(&socket, filter, json, fields, since).await?;
|
stream_events(&socket, pattern, json, fields, since).await?;
|
||||||
}
|
}
|
||||||
Commands::Modules { subcommand } => {
|
Commands::Modules { subcommand } => {
|
||||||
handle_modules_cmd(subcommand, &socket).await?;
|
handle_modules_cmd(subcommand, &socket).await?;
|
||||||
|
|
@ -769,8 +769,7 @@ fn run_package_installs(packages_dir: &Path, managers: &[String]) -> Result<()>
|
||||||
}
|
}
|
||||||
"pip" => {
|
"pip" => {
|
||||||
let mut cmd = std::process::Command::new("pip");
|
let mut cmd = std::process::Command::new("pip");
|
||||||
cmd.args(["install", "--user", "-r"])
|
cmd.args(["install", "--user", "-r"]).arg(&file);
|
||||||
.arg(file.to_str().unwrap_or(""));
|
|
||||||
let _ = cmd.status();
|
let _ = cmd.status();
|
||||||
}
|
}
|
||||||
"npm" => {
|
"npm" => {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ tracing-subscriber.workspace = true
|
||||||
mlua = { version = "0.9", features = ["lua54", "vendored", "async", "serialize"] }
|
mlua = { version = "0.9", features = ["lua54", "vendored", "async", "serialize"] }
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
udev = "0.9"
|
udev = { version = "0.9", features = ["send"] }
|
||||||
rtnetlink = "0.9"
|
rtnetlink = "0.9"
|
||||||
zbus = { version = "3.13", features = ["tokio"] }
|
zbus = { version = "3.13", features = ["tokio"] }
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
|
|
||||||
|
|
@ -48,13 +48,36 @@ impl Adapter for HyprlandAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hyprland_event_socket() -> Result<PathBuf> {
|
fn hyprland_event_socket() -> Result<PathBuf> {
|
||||||
let instance = env::var("HYPRLAND_INSTANCE_SIGNATURE")
|
|
||||||
.map_err(|_| anyhow!("HYPRLAND_INSTANCE_SIGNATURE is not set"))?;
|
|
||||||
let runtime = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
|
let runtime = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
|
||||||
Ok(PathBuf::from(runtime)
|
|
||||||
.join("hypr")
|
// If the env var is set, use it directly.
|
||||||
.join(instance)
|
if let Ok(instance) = env::var("HYPRLAND_INSTANCE_SIGNATURE") {
|
||||||
.join(".socket2.sock"))
|
return Ok(PathBuf::from(runtime)
|
||||||
|
.join("hypr")
|
||||||
|
.join(instance)
|
||||||
|
.join(".socket2.sock"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise scan $XDG_RUNTIME_DIR/hypr/ for a running instance.
|
||||||
|
// Hyprland creates a per-instance directory there containing .socket2.sock.
|
||||||
|
// This handles the case where breadd starts as a systemd user service before
|
||||||
|
// Hyprland has exported HYPRLAND_INSTANCE_SIGNATURE into the environment.
|
||||||
|
let hypr_dir = PathBuf::from(&runtime).join("hypr");
|
||||||
|
let mut sockets: Vec<PathBuf> = std::fs::read_dir(&hypr_dir)
|
||||||
|
.map_err(|_| anyhow!("no Hyprland instance found ({})", hypr_dir.display()))?
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.path().join(".socket2.sock"))
|
||||||
|
.filter(|p| p.exists())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
match sockets.len() {
|
||||||
|
0 => Err(anyhow!("no Hyprland instance found in {}", hypr_dir.display())),
|
||||||
|
1 => Ok(sockets.remove(0)),
|
||||||
|
n => {
|
||||||
|
warn!("found {n} Hyprland instances, using first");
|
||||||
|
Ok(sockets.remove(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_hyprland_line(line: &str) -> (String, String) {
|
fn parse_hyprland_line(line: &str) -> (String, String) {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
use std::collections::HashMap;
|
use std::os::unix::io::AsRawFd;
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
|
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::time::{sleep, Duration};
|
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::adapters::Adapter;
|
use crate::adapters::Adapter;
|
||||||
|
|
@ -22,10 +19,7 @@ impl UdevAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn enumerate_existing(&self, tx: &mpsc::Sender<RawEvent>) -> Result<()> {
|
pub async fn enumerate_existing(&self, tx: &mpsc::Sender<RawEvent>) -> Result<()> {
|
||||||
let devices = enumerate_with_udev(&self.subsystems).unwrap_or_else(|_| {
|
let devices = enumerate_with_udev(&self.subsystems)?;
|
||||||
scan_devices(&self.subsystems).unwrap_or_default()
|
|
||||||
});
|
|
||||||
|
|
||||||
for device in devices {
|
for device in devices {
|
||||||
tx.send(RawEvent {
|
tx.send(RawEvent {
|
||||||
source: AdapterSource::Udev,
|
source: AdapterSource::Udev,
|
||||||
|
|
@ -52,122 +46,106 @@ impl Adapter for UdevAdapter {
|
||||||
|
|
||||||
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
async fn run(&self, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||||
debug!("udev adapter started");
|
debug!("udev adapter started");
|
||||||
match run_udev_monitor(self.subsystems.clone(), tx.clone()).await {
|
run_udev_monitor(self.subsystems.clone(), tx).await
|
||||||
Ok(()) => return Ok(()),
|
|
||||||
Err(err) => {
|
|
||||||
tracing::warn!(error = %err, "udev netlink monitor unavailable, falling back to sysfs polling (add user to 'plugdev' group for real-time events)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: poll sysfs every 2 seconds for environments where the
|
|
||||||
// netlink socket is unavailable (missing plugdev membership, containers, etc).
|
|
||||||
let mut known: HashMap<String, ScannedDevice> = scan_devices(&self.subsystems)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.into_iter()
|
|
||||||
.map(|d| (d.id.clone(), d))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let current = scan_devices(&self.subsystems).unwrap_or_default();
|
|
||||||
let current_map: HashMap<String, ScannedDevice> = current
|
|
||||||
.into_iter()
|
|
||||||
.map(|d| (d.id.clone(), d))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for (id, dev) in ¤t_map {
|
|
||||||
if !known.contains_key(id) {
|
|
||||||
if tx.send(raw_change_event("add", dev)).await.is_err() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (id, dev) in &known {
|
|
||||||
if !current_map.contains_key(id) {
|
|
||||||
if tx.send(raw_change_event("remove", dev)).await.is_err() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
known = current_map;
|
|
||||||
sleep(Duration::from_secs(2)).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
struct ScannedDevice {
|
struct ScannedDevice {
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
subsystem: String,
|
subsystem: String,
|
||||||
vendor_id: Option<String>,
|
|
||||||
product_id: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// udev::MonitorSocket uses a non-blocking socket; calling iter().next() without
|
||||||
|
// first polling the fd returns None immediately and exits the loop — which is
|
||||||
|
// why the old code silently fell back to sysfs on every start. We use poll(2)
|
||||||
|
// inside spawn_blocking so the thread truly blocks until events are available.
|
||||||
async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -> Result<()> {
|
||||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||||
let mut builder = udev::MonitorBuilder::new()?;
|
let mut builder = udev::MonitorBuilder::new()?;
|
||||||
for subsystem in &subsystems {
|
for subsystem in &subsystems {
|
||||||
builder = builder.match_subsystem(subsystem)?;
|
builder = builder.match_subsystem(subsystem)?;
|
||||||
}
|
}
|
||||||
let monitor = builder.listen()?;
|
let socket = builder.listen()?;
|
||||||
|
let fd = socket.as_raw_fd();
|
||||||
|
|
||||||
for event in monitor.iter() {
|
loop {
|
||||||
let action = event
|
let mut pfd = libc::pollfd {
|
||||||
.action()
|
fd,
|
||||||
.map(|a| a.to_string_lossy().to_string())
|
events: libc::POLLIN,
|
||||||
.unwrap_or_else(|| "change".to_string());
|
revents: 0,
|
||||||
let subsystem = event
|
|
||||||
.subsystem()
|
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
|
||||||
let name = event
|
|
||||||
.property_value("ID_MODEL")
|
|
||||||
.or_else(|| event.property_value("NAME"))
|
|
||||||
.map(|v| v.to_string_lossy().to_string())
|
|
||||||
.or_else(|| event.devnode().map(|n| n.display().to_string()))
|
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
|
||||||
let id = event
|
|
||||||
.syspath()
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let msg = RawEvent {
|
|
||||||
source: AdapterSource::Udev,
|
|
||||||
kind: "udev.change".to_string(),
|
|
||||||
payload: json!({
|
|
||||||
"action": action,
|
|
||||||
"id": id,
|
|
||||||
"name": name,
|
|
||||||
"subsystem": subsystem,
|
|
||||||
"id_input_keyboard": prop_bool(&event, "ID_INPUT_KEYBOARD"),
|
|
||||||
"id_input_mouse": prop_bool(&event, "ID_INPUT_MOUSE"),
|
|
||||||
"id_input_joystick": prop_bool(&event, "ID_INPUT_JOYSTICK"),
|
|
||||||
"id_input_touchpad": prop_bool(&event, "ID_INPUT_TOUCHPAD"),
|
|
||||||
"id_input_tablet": prop_bool(&event, "ID_INPUT_TABLET"),
|
|
||||||
"id_usb_class": prop_str(&event, "ID_USB_CLASS"),
|
|
||||||
"id_usb_interfaces": prop_str(&event, "ID_USB_INTERFACES"),
|
|
||||||
"id_vendor": prop_str(&event, "ID_VENDOR"),
|
|
||||||
"id_model": prop_str(&event, "ID_MODEL"),
|
|
||||||
"vendor_id": prop_str(&event, "ID_VENDOR_ID"),
|
|
||||||
"product_id": prop_str(&event, "ID_MODEL_ID"),
|
|
||||||
}),
|
|
||||||
timestamp: now_unix_ms(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if tx.blocking_send(msg).is_err() {
|
let ret = unsafe { libc::poll(&mut pfd, 1, 1000) };
|
||||||
break;
|
if ret < 0 {
|
||||||
|
let err = std::io::Error::last_os_error();
|
||||||
|
if err.kind() == std::io::ErrorKind::Interrupted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Err(err.into());
|
||||||
|
}
|
||||||
|
if ret == 0 {
|
||||||
|
// Timeout: bail if the downstream channel has been dropped.
|
||||||
|
if tx.is_closed() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if pfd.revents & libc::POLLIN != 0 {
|
||||||
|
while let Some(event) = socket.iter().next() {
|
||||||
|
if tx.blocking_send(build_event(&event)).is_err() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
|
||||||
})
|
})
|
||||||
.await??;
|
.await??;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_event(event: &udev::Event) -> RawEvent {
|
||||||
|
let action = event
|
||||||
|
.action()
|
||||||
|
.map(|a| a.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "change".to_string());
|
||||||
|
let subsystem = event
|
||||||
|
.subsystem()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let name = event
|
||||||
|
.property_value("ID_MODEL")
|
||||||
|
.or_else(|| event.property_value("NAME"))
|
||||||
|
.map(|v| v.to_string_lossy().to_string())
|
||||||
|
.or_else(|| event.devnode().map(|n| n.display().to_string()))
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let id = event.syspath().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
RawEvent {
|
||||||
|
source: AdapterSource::Udev,
|
||||||
|
kind: "udev.change".to_string(),
|
||||||
|
payload: json!({
|
||||||
|
"action": action,
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"subsystem": subsystem,
|
||||||
|
"id_input_keyboard": prop_bool(event, "ID_INPUT_KEYBOARD"),
|
||||||
|
"id_input_mouse": prop_bool(event, "ID_INPUT_MOUSE"),
|
||||||
|
"id_input_joystick": prop_bool(event, "ID_INPUT_JOYSTICK"),
|
||||||
|
"id_input_touchpad": prop_bool(event, "ID_INPUT_TOUCHPAD"),
|
||||||
|
"id_input_tablet": prop_bool(event, "ID_INPUT_TABLET"),
|
||||||
|
"id_usb_class": prop_str(event, "ID_USB_CLASS"),
|
||||||
|
"id_usb_interfaces": prop_str(event, "ID_USB_INTERFACES"),
|
||||||
|
"id_vendor": prop_str(event, "ID_VENDOR"),
|
||||||
|
"id_model": prop_str(event, "ID_MODEL"),
|
||||||
|
"vendor_id": prop_str(event, "ID_VENDOR_ID"),
|
||||||
|
"product_id": prop_str(event, "ID_MODEL_ID"),
|
||||||
|
}),
|
||||||
|
timestamp: now_unix_ms(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
|
fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
|
||||||
let mut enumerator = udev::Enumerator::new()?;
|
let mut enumerator = udev::Enumerator::new()?;
|
||||||
for subsystem in subsystems {
|
for subsystem in subsystems {
|
||||||
|
|
@ -187,125 +165,7 @@ fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
|
||||||
.or_else(|| dev.sysname().to_str().map(ToString::to_string))
|
.or_else(|| dev.sysname().to_str().map(ToString::to_string))
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
let id = dev.syspath().to_string_lossy().to_string();
|
let id = dev.syspath().to_string_lossy().to_string();
|
||||||
let vendor_id = dev
|
out.push(ScannedDevice { id, name, subsystem });
|
||||||
.property_value("ID_VENDOR_ID")
|
|
||||||
.map(|v| v.to_string_lossy().to_string());
|
|
||||||
let product_id = dev
|
|
||||||
.property_value("ID_MODEL_ID")
|
|
||||||
.map(|v| v.to_string_lossy().to_string());
|
|
||||||
|
|
||||||
out.push(ScannedDevice {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
subsystem,
|
|
||||||
vendor_id,
|
|
||||||
product_id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn raw_change_event(action: &str, dev: &ScannedDevice) -> RawEvent {
|
|
||||||
RawEvent {
|
|
||||||
source: AdapterSource::Udev,
|
|
||||||
kind: "udev.change".to_string(),
|
|
||||||
payload: json!({
|
|
||||||
"action": action,
|
|
||||||
"id": dev.id,
|
|
||||||
"name": dev.name,
|
|
||||||
"subsystem": dev.subsystem,
|
|
||||||
"vendor_id": dev.vendor_id,
|
|
||||||
"product_id": dev.product_id,
|
|
||||||
}),
|
|
||||||
timestamp: now_unix_ms(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scan_devices(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
|
|
||||||
if subsystems.iter().any(|s| s == "drm") {
|
|
||||||
let drm_dir = Path::new("/sys/class/drm");
|
|
||||||
if drm_dir.exists() {
|
|
||||||
for entry in fs::read_dir(drm_dir)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
|
||||||
if !name.contains('-') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let status = fs::read_to_string(entry.path().join("status")).unwrap_or_default();
|
|
||||||
if status.trim() == "connected" {
|
|
||||||
out.push(ScannedDevice {
|
|
||||||
id: format!("drm:{name}"),
|
|
||||||
name,
|
|
||||||
subsystem: "drm".to_string(),
|
|
||||||
vendor_id: None,
|
|
||||||
product_id: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if subsystems.iter().any(|s| s == "input") {
|
|
||||||
let input_dir = Path::new("/dev/input/by-id");
|
|
||||||
if input_dir.exists() {
|
|
||||||
for entry in fs::read_dir(input_dir)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
|
||||||
out.push(ScannedDevice {
|
|
||||||
id: format!("input:{name}"),
|
|
||||||
name,
|
|
||||||
subsystem: "input".to_string(),
|
|
||||||
vendor_id: None,
|
|
||||||
product_id: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if subsystems.iter().any(|s| s == "power_supply") {
|
|
||||||
let pwr_dir = Path::new("/sys/class/power_supply");
|
|
||||||
if pwr_dir.exists() {
|
|
||||||
for entry in fs::read_dir(pwr_dir)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
|
||||||
out.push(ScannedDevice {
|
|
||||||
id: format!("power_supply:{name}"),
|
|
||||||
name,
|
|
||||||
subsystem: "power_supply".to_string(),
|
|
||||||
vendor_id: None,
|
|
||||||
product_id: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if subsystems.iter().any(|s| s == "usb") {
|
|
||||||
let usb_dir = Path::new("/sys/bus/usb/devices");
|
|
||||||
if usb_dir.exists() {
|
|
||||||
for entry in fs::read_dir(usb_dir)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
|
||||||
if !name.contains(':') && name.chars().any(|c| c.is_ascii_digit()) {
|
|
||||||
let syspath = entry.path();
|
|
||||||
let vendor_id = fs::read_to_string(syspath.join("idVendor"))
|
|
||||||
.ok()
|
|
||||||
.map(|s| s.trim().to_string());
|
|
||||||
let product_id = fs::read_to_string(syspath.join("idProduct"))
|
|
||||||
.ok()
|
|
||||||
.map(|s| s.trim().to_string());
|
|
||||||
out.push(ScannedDevice {
|
|
||||||
id: format!("usb:{name}"),
|
|
||||||
name,
|
|
||||||
subsystem: "usb".to_string(),
|
|
||||||
vendor_id,
|
|
||||||
product_id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(out)
|
Ok(out)
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,16 @@ use std::sync::RwLock;
|
||||||
use bread_shared::{AdapterSource, BreadEvent, RawEvent};
|
use bread_shared::{AdapterSource, BreadEvent, RawEvent};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::core::types::DeviceClass;
|
|
||||||
|
|
||||||
/// How many multiples of `dedup_window_ms` an entry must be idle before eviction.
|
/// How many multiples of `dedup_window_ms` an entry must be idle before eviction.
|
||||||
const EVICT_MULTIPLIER: u64 = 60;
|
const EVICT_MULTIPLIER: u64 = 60;
|
||||||
|
|
||||||
pub struct EventNormalizer {
|
pub struct EventNormalizer {
|
||||||
dedup_window_ms: u64,
|
dedup_window_ms: u64,
|
||||||
recent: RwLock<HashMap<String, u64>>,
|
recent: RwLock<HashMap<String, u64>>,
|
||||||
|
/// Tracks the first time a physical device (keyed by verb+vendor_id+product_id)
|
||||||
|
/// fired within the current window, so subsequent child-node events from the
|
||||||
|
/// same plug-in are suppressed at the normalizer level.
|
||||||
|
seen_devices: RwLock<HashMap<String, u64>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventNormalizer {
|
impl EventNormalizer {
|
||||||
|
|
@ -19,6 +21,7 @@ impl EventNormalizer {
|
||||||
Self {
|
Self {
|
||||||
dedup_window_ms,
|
dedup_window_ms,
|
||||||
recent: RwLock::new(HashMap::new()),
|
recent: RwLock::new(HashMap::new()),
|
||||||
|
seen_devices: RwLock::new(HashMap::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,40 +45,75 @@ impl EventNormalizer {
|
||||||
|
|
||||||
fn normalize_udev(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
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");
|
||||||
let id = raw.payload.get("id").and_then(Value::as_str).unwrap_or("unknown");
|
|
||||||
let class = classify_device(&raw.payload);
|
|
||||||
let class_str = serde_json::to_string(&class)
|
|
||||||
.unwrap_or_else(|_| "\"unknown\"".to_string())
|
|
||||||
.replace('"', "");
|
|
||||||
|
|
||||||
|
// "bind" is the kernel attaching a driver to an interface — not a meaningful
|
||||||
|
// device state change for automation purposes.
|
||||||
|
if action == "bind" {
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
if name == "unknown" && vendor.is_empty() && vendor_id.is_empty() {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
|
// For connected/disconnected, suppress duplicate events from child nodes of
|
||||||
|
// the same physical device (e.g. input66, mouse0, event17 all from one plug-in).
|
||||||
|
// Key by verb+vendor_id+product_id so a second distinct device of the same
|
||||||
|
// model plugged in after the window still fires correctly.
|
||||||
let verb = match action {
|
let verb = match action {
|
||||||
"add" => "connected",
|
"add" => "connected",
|
||||||
"remove" => "disconnected",
|
"remove" => "disconnected",
|
||||||
_ => "changed",
|
_ => "changed",
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut events = vec![BreadEvent {
|
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 = {
|
||||||
|
let seen = self.seen_devices.read().unwrap_or_else(|p| p.into_inner());
|
||||||
|
seen.get(&device_key)
|
||||||
|
.map(|&last| now.saturating_sub(last) < self.dedup_window_ms)
|
||||||
|
.unwrap_or(false)
|
||||||
|
};
|
||||||
|
if already_seen {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
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));
|
||||||
|
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");
|
||||||
|
|
||||||
|
// Device name is always "unknown" here; the state engine applies user-defined
|
||||||
|
// classification rules from devices.lua before dispatching to subscribers.
|
||||||
|
vec![BreadEvent {
|
||||||
event: format!("bread.device.{}", verb),
|
event: format!("bread.device.{}", verb),
|
||||||
timestamp: raw.timestamp,
|
timestamp: raw.timestamp,
|
||||||
source: AdapterSource::Udev,
|
source: AdapterSource::Udev,
|
||||||
data: json!({
|
data: json!({
|
||||||
"id": id,
|
"id": id,
|
||||||
"class": class,
|
"device": "unknown",
|
||||||
|
"name": name,
|
||||||
|
"vendor": vendor,
|
||||||
|
"vendor_id": vendor_id,
|
||||||
|
"product_id": product_id,
|
||||||
|
"subsystem": subsystem,
|
||||||
"raw": raw.payload,
|
"raw": raw.payload,
|
||||||
}),
|
}),
|
||||||
}];
|
}]
|
||||||
|
|
||||||
events.push(BreadEvent {
|
|
||||||
event: format!("bread.device.{}.{}", class_str, verb),
|
|
||||||
timestamp: raw.timestamp,
|
|
||||||
source: AdapterSource::Udev,
|
|
||||||
data: json!({
|
|
||||||
"id": id,
|
|
||||||
"class": class,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
events
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_hyprland(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
fn normalize_hyprland(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||||
|
|
@ -109,13 +147,13 @@ impl EventNormalizer {
|
||||||
event: "bread.monitor.connected".to_string(),
|
event: "bread.monitor.connected".to_string(),
|
||||||
timestamp: raw.timestamp,
|
timestamp: raw.timestamp,
|
||||||
source: AdapterSource::Hyprland,
|
source: AdapterSource::Hyprland,
|
||||||
data: raw.payload.clone(),
|
data: json!({ "name": data }),
|
||||||
}],
|
}],
|
||||||
"monitorremoved" => vec![BreadEvent {
|
"monitorremoved" => vec![BreadEvent {
|
||||||
event: "bread.monitor.disconnected".to_string(),
|
event: "bread.monitor.disconnected".to_string(),
|
||||||
timestamp: raw.timestamp,
|
timestamp: raw.timestamp,
|
||||||
source: AdapterSource::Hyprland,
|
source: AdapterSource::Hyprland,
|
||||||
data: raw.payload.clone(),
|
data: json!({ "name": data }),
|
||||||
}],
|
}],
|
||||||
"activewindow" => vec![BreadEvent {
|
"activewindow" => vec![BreadEvent {
|
||||||
event: "bread.window.focus.changed".to_string(),
|
event: "bread.window.focus.changed".to_string(),
|
||||||
|
|
@ -288,107 +326,3 @@ fn split_hyprland_fields(data: &str) -> Vec<&str> {
|
||||||
data.split(">>").collect()
|
data.split(">>").collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn classify_device(payload: &Value) -> DeviceClass {
|
|
||||||
let subsystem = payload
|
|
||||||
.get("subsystem")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_lowercase();
|
|
||||||
|
|
||||||
// --- Property-based classification (reliable, hardware-agnostic) ---
|
|
||||||
|
|
||||||
// udev sets ID_INPUT_KEYBOARD=1 for anything that presents as a keyboard HID device.
|
|
||||||
if payload.get("id_input_keyboard").and_then(Value::as_bool).unwrap_or(false) {
|
|
||||||
return DeviceClass::Keyboard;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ID_INPUT_MOUSE=1 covers mice and trackballs.
|
|
||||||
if payload.get("id_input_mouse").and_then(Value::as_bool).unwrap_or(false) {
|
|
||||||
return DeviceClass::Mouse;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ID_INPUT_TABLET=1 covers drawing tablets (Wacom etc).
|
|
||||||
if payload.get("id_input_tablet").and_then(Value::as_bool).unwrap_or(false) {
|
|
||||||
return DeviceClass::Tablet;
|
|
||||||
}
|
|
||||||
|
|
||||||
// USB class 0x09 = Hub. Docks expose a hub interface; they also typically
|
|
||||||
// expose video (0x0e), audio (0x01), and ethernet (CDC 0x02) interfaces.
|
|
||||||
// We check for hub + at least one of those secondary interfaces.
|
|
||||||
if let Some(ifaces) = payload.get("id_usb_interfaces").and_then(Value::as_str) {
|
|
||||||
let ifaces_lc = ifaces.to_lowercase();
|
|
||||||
let has_hub = ifaces_lc.contains(":0900") || ifaces_lc.contains(":0902");
|
|
||||||
let has_secondary = ifaces_lc.contains(":0e") // video
|
|
||||||
|| ifaces_lc.contains(":0200") // CDC ethernet
|
|
||||||
|| ifaces_lc.contains(":0100") // audio
|
|
||||||
|| ifaces_lc.contains(":0801"); // mass storage
|
|
||||||
if has_hub && has_secondary {
|
|
||||||
return DeviceClass::Dock;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// USB class 0x01 = Audio.
|
|
||||||
if let Some(cls) = payload.get("id_usb_class").and_then(Value::as_str) {
|
|
||||||
if cls == "01" || cls.to_lowercase() == "0x01" {
|
|
||||||
return DeviceClass::Audio;
|
|
||||||
}
|
|
||||||
// USB class 0x08 = Mass Storage.
|
|
||||||
if cls == "08" || cls.to_lowercase() == "0x08" {
|
|
||||||
return DeviceClass::Storage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DRM subsystem = display connector.
|
|
||||||
if subsystem == "drm" {
|
|
||||||
return DeviceClass::Display;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block devices = storage.
|
|
||||||
if subsystem == "block" {
|
|
||||||
return DeviceClass::Storage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sound subsystem = audio.
|
|
||||||
if subsystem == "sound" {
|
|
||||||
return DeviceClass::Audio;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Name-based fallback (catches user-registered patterns and obvious names) ---
|
|
||||||
// This runs last so the property-based rules above always win.
|
|
||||||
|
|
||||||
let name = payload
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.or_else(|| payload.get("id_model").and_then(Value::as_str))
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_lowercase();
|
|
||||||
|
|
||||||
let vendor = payload
|
|
||||||
.get("id_vendor")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_lowercase();
|
|
||||||
|
|
||||||
let combined = format!("{name} {vendor}");
|
|
||||||
|
|
||||||
if combined.contains("dock") || combined.contains("hub") || combined.contains("thunderbolt") {
|
|
||||||
return DeviceClass::Dock;
|
|
||||||
}
|
|
||||||
if combined.contains("keyboard") || combined.contains("kbd") {
|
|
||||||
return DeviceClass::Keyboard;
|
|
||||||
}
|
|
||||||
if combined.contains("mouse") || combined.contains("trackball") || combined.contains("trackpoint") {
|
|
||||||
return DeviceClass::Mouse;
|
|
||||||
}
|
|
||||||
if combined.contains("tablet") || combined.contains("wacom") || combined.contains("stylus") {
|
|
||||||
return DeviceClass::Tablet;
|
|
||||||
}
|
|
||||||
if combined.contains("audio") || combined.contains("headset") || combined.contains("speaker") || combined.contains("dac") {
|
|
||||||
return DeviceClass::Audio;
|
|
||||||
}
|
|
||||||
if combined.contains("storage") || combined.contains("drive") || combined.contains("flash") || combined.contains("disk") {
|
|
||||||
return DeviceClass::Storage;
|
|
||||||
}
|
|
||||||
|
|
||||||
DeviceClass::Unknown
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use tokio::sync::{broadcast, mpsc, watch, RwLock};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::core::subscriptions::{SubscriptionId, SubscriptionTable};
|
use crate::core::subscriptions::{SubscriptionId, SubscriptionTable};
|
||||||
use crate::core::types::{Device, DeviceClass, InterfaceState, ModuleLoadState, RuntimeState};
|
use crate::core::types::{Device, DeviceRule, InterfaceState, MatchCondition, ModuleLoadState, RuntimeState};
|
||||||
use crate::lua::LuaMessage;
|
use crate::lua::LuaMessage;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -46,6 +46,7 @@ pub enum StateCommand {
|
||||||
SetProfile {
|
SetProfile {
|
||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
|
SetDeviceRules(Vec<DeviceRule>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StateHandle {
|
impl StateHandle {
|
||||||
|
|
@ -136,6 +137,10 @@ impl StateHandle {
|
||||||
let _ = self.command_tx.send(StateCommand::SetProfile { name });
|
let _ = self.command_tx.send(StateCommand::SetProfile { name });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_device_rules(&self, rules: Vec<DeviceRule>) {
|
||||||
|
let _ = self.command_tx.send(StateCommand::SetDeviceRules(rules));
|
||||||
|
}
|
||||||
|
|
||||||
pub fn subscription_count(&self) -> Arc<AtomicU64> {
|
pub fn subscription_count(&self) -> Arc<AtomicU64> {
|
||||||
self.subscription_count.clone()
|
self.subscription_count.clone()
|
||||||
}
|
}
|
||||||
|
|
@ -152,6 +157,7 @@ pub async fn run_state_engine(
|
||||||
) {
|
) {
|
||||||
let mut subscriptions = SubscriptionTable::default();
|
let mut subscriptions = SubscriptionTable::default();
|
||||||
let mut watches: HashMap<SubscriptionId, String> = HashMap::new();
|
let mut watches: HashMap<SubscriptionId, String> = HashMap::new();
|
||||||
|
let mut device_rules: Vec<DeviceRule> = Vec::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
|
|
@ -164,13 +170,51 @@ pub async fn run_state_engine(
|
||||||
let Some(cmd) = maybe_cmd else {
|
let Some(cmd) = maybe_cmd else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
handle_command(cmd, &state, &mut subscriptions, &mut watches, &subscription_count).await;
|
if let StateCommand::SetDeviceRules(rules) = cmd {
|
||||||
|
device_rules = rules;
|
||||||
|
} else {
|
||||||
|
handle_command(cmd, &state, &mut subscriptions, &mut watches, &subscription_count).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
maybe_event = event_rx.recv() => {
|
maybe_event = event_rx.recv() => {
|
||||||
let Some(event) = maybe_event else {
|
let Some(mut event) = maybe_event else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Resolve device name from user rules and patch the event data before
|
||||||
|
// any subscriber sees it, then emit the named companion event.
|
||||||
|
let device_event = if event.event == "bread.device.connected"
|
||||||
|
|| event.event == "bread.device.disconnected"
|
||||||
|
{
|
||||||
|
let is_disconnect = event.event == "bread.device.disconnected";
|
||||||
|
let id = event.data.get("id").and_then(Value::as_str).unwrap_or("unknown").to_string();
|
||||||
|
|
||||||
|
// On disconnect, udev strips vendor/product identifiers from the event.
|
||||||
|
// Look up the device by id in the current state (it's still present
|
||||||
|
// because apply_event_to_state hasn't run yet) and reuse the stored name.
|
||||||
|
let device = if is_disconnect {
|
||||||
|
state.read().await
|
||||||
|
.devices.connected.iter()
|
||||||
|
.find(|d| d.id == id)
|
||||||
|
.map(|d| d.device.clone())
|
||||||
|
.unwrap_or_else(|| resolve_device(&device_rules, &event.data))
|
||||||
|
} else {
|
||||||
|
resolve_device(&device_rules, &event.data)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(data) = event.data.as_object_mut() {
|
||||||
|
data.insert("device".to_string(), Value::String(device.clone()));
|
||||||
|
}
|
||||||
|
let verb = if is_disconnect { "disconnected" } else { "connected" };
|
||||||
|
Some(BreadEvent::new(
|
||||||
|
format!("bread.device.{}.{}", device, verb),
|
||||||
|
AdapterSource::Udev,
|
||||||
|
json!({ "id": id, "device": device }),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let (before_snapshot, after_snapshot) = if watches.is_empty() {
|
let (before_snapshot, after_snapshot) = if watches.is_empty() {
|
||||||
(None, None)
|
(None, None)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -188,6 +232,13 @@ pub async fn run_state_engine(
|
||||||
|
|
||||||
dispatch_event(&event, &mut subscriptions, &lua_tx, &event_stream_tx, &subscription_count);
|
dispatch_event(&event, &mut subscriptions, &lua_tx, &event_stream_tx, &subscription_count);
|
||||||
|
|
||||||
|
if let Some(dev_ev) = device_event {
|
||||||
|
let mut guard = state.write().await;
|
||||||
|
apply_event_to_state(&mut guard, &dev_ev);
|
||||||
|
drop(guard);
|
||||||
|
dispatch_event(&dev_ev, &mut subscriptions, &lua_tx, &event_stream_tx, &subscription_count);
|
||||||
|
}
|
||||||
|
|
||||||
if let (Some(before), Some(after)) = (before_snapshot, after_snapshot) {
|
if let (Some(before), Some(after)) = (before_snapshot, after_snapshot) {
|
||||||
for (_id, path) in watches.iter() {
|
for (_id, path) in watches.iter() {
|
||||||
let old_val = value_at_path(&before, path).unwrap_or(Value::Null);
|
let old_val = value_at_path(&before, path).unwrap_or(Value::Null);
|
||||||
|
|
@ -273,6 +324,9 @@ async fn handle_command(
|
||||||
guard.profile.active = name;
|
guard.profile.active = name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
StateCommand::SetDeviceRules(_) => {
|
||||||
|
// Handled directly in run_state_engine before this function is called.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -399,6 +453,95 @@ fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_device(rules: &[DeviceRule], data: &Value) -> String {
|
||||||
|
for rule in rules {
|
||||||
|
if !rule.conditions.is_empty() && rule.conditions.iter().all(|c| condition_matches(c, data)) {
|
||||||
|
return rule.device.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"unknown".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn condition_matches(cond: &MatchCondition, data: &Value) -> bool {
|
||||||
|
if let Some(ref expected) = cond.vendor_id {
|
||||||
|
let actual = data.get("vendor_id").and_then(Value::as_str).unwrap_or("");
|
||||||
|
if actual.to_lowercase() != expected.to_lowercase() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref expected) = cond.product_id {
|
||||||
|
let actual = data.get("product_id").and_then(Value::as_str).unwrap_or("");
|
||||||
|
if actual.to_lowercase() != expected.to_lowercase() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref expected) = cond.name {
|
||||||
|
let actual = data.get("name").and_then(Value::as_str).unwrap_or("").to_lowercase();
|
||||||
|
if actual != expected.to_lowercase() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref expected) = cond.vendor {
|
||||||
|
let actual = data.get("vendor").and_then(Value::as_str).unwrap_or("").to_lowercase();
|
||||||
|
if actual != expected.to_lowercase() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref contains) = cond.name_contains {
|
||||||
|
let name = data.get("name").and_then(Value::as_str).unwrap_or("").to_lowercase();
|
||||||
|
let vendor = data.get("vendor").and_then(Value::as_str).unwrap_or("").to_lowercase();
|
||||||
|
let combined = format!("{name} {vendor}");
|
||||||
|
if !combined.contains(contains.to_lowercase().as_str()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(expected) = cond.id_input_keyboard {
|
||||||
|
if data.get("id_input_keyboard").and_then(Value::as_bool).unwrap_or(false) != expected {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(expected) = cond.id_input_mouse {
|
||||||
|
if data.get("id_input_mouse").and_then(Value::as_bool).unwrap_or(false) != expected {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(expected) = cond.id_input_tablet {
|
||||||
|
if data.get("id_input_tablet").and_then(Value::as_bool).unwrap_or(false) != expected {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cond.usb_hub == Some(true) {
|
||||||
|
let ifaces = data
|
||||||
|
.get("id_usb_interfaces")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
|
let has_hub = ifaces.contains(":0900") || ifaces.contains(":0902");
|
||||||
|
let has_secondary = ifaces.contains(":0e")
|
||||||
|
|| ifaces.contains(":0200")
|
||||||
|
|| ifaces.contains(":0100")
|
||||||
|
|| ifaces.contains(":0801");
|
||||||
|
if !(has_hub && has_secondary) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref expected) = cond.id_usb_class {
|
||||||
|
let actual = data.get("id_usb_class").and_then(Value::as_str).unwrap_or("");
|
||||||
|
if actual.to_lowercase() != expected.to_lowercase()
|
||||||
|
&& actual.to_lowercase() != format!("0x{}", expected.to_lowercase())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref expected) = cond.subsystem {
|
||||||
|
let actual = data.get("subsystem").and_then(Value::as_str).unwrap_or("").to_lowercase();
|
||||||
|
if actual != expected.to_lowercase() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool) {
|
fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool) {
|
||||||
let id = data
|
let id = data
|
||||||
.get("id")
|
.get("id")
|
||||||
|
|
@ -411,10 +554,11 @@ fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let class = data
|
let device = data
|
||||||
.get("class")
|
.get("device")
|
||||||
.and_then(|v| serde_json::from_value::<DeviceClass>(v.clone()).ok())
|
.and_then(Value::as_str)
|
||||||
.unwrap_or(DeviceClass::Unknown);
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
state.devices.connected.push(Device {
|
state.devices.connected.push(Device {
|
||||||
id,
|
id,
|
||||||
|
|
@ -423,7 +567,7 @@ fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool)
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("unknown")
|
.unwrap_or("unknown")
|
||||||
.to_string(),
|
.to_string(),
|
||||||
class,
|
device,
|
||||||
subsystem: data
|
subsystem: data
|
||||||
.get("subsystem")
|
.get("subsystem")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ pub struct DeviceTopology {
|
||||||
pub struct Device {
|
pub struct Device {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub class: DeviceClass,
|
pub device: String,
|
||||||
pub subsystem: String,
|
pub subsystem: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub vendor_id: Option<String>,
|
pub vendor_id: Option<String>,
|
||||||
|
|
@ -63,17 +63,30 @@ pub struct Device {
|
||||||
pub product_id: Option<String>,
|
pub product_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
|
/// One set of match conditions. All provided fields must match.
|
||||||
#[serde(rename_all = "snake_case")]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub enum DeviceClass {
|
pub struct MatchCondition {
|
||||||
Dock,
|
pub vendor_id: Option<String>,
|
||||||
Keyboard,
|
pub product_id: Option<String>,
|
||||||
Mouse,
|
pub name: Option<String>,
|
||||||
Tablet,
|
pub vendor: Option<String>,
|
||||||
Display,
|
pub name_contains: Option<String>,
|
||||||
Storage,
|
pub id_input_keyboard: Option<bool>,
|
||||||
Audio,
|
pub id_input_mouse: Option<bool>,
|
||||||
Unknown,
|
pub id_input_tablet: Option<bool>,
|
||||||
|
/// True triggers the compound USB hub + secondary-interface check.
|
||||||
|
pub usb_hub: Option<bool>,
|
||||||
|
pub id_usb_class: Option<String>,
|
||||||
|
pub subsystem: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A device rule from `devices.lua`. The device name is assigned if ANY
|
||||||
|
/// condition in `conditions` matches (OR semantics across conditions,
|
||||||
|
/// AND semantics within a condition).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DeviceRule {
|
||||||
|
pub device: String,
|
||||||
|
pub conditions: Vec<MatchCondition>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ use tracing::{error, info, warn};
|
||||||
use crate::core::config::{Config, ModulesConfig, NotificationsConfig};
|
use crate::core::config::{Config, ModulesConfig, NotificationsConfig};
|
||||||
use crate::core::state_engine::StateHandle;
|
use crate::core::state_engine::StateHandle;
|
||||||
use crate::core::subscriptions::SubscriptionId;
|
use crate::core::subscriptions::SubscriptionId;
|
||||||
use crate::core::types::{ModuleLoadState, RuntimeState};
|
use crate::core::types::{DeviceRule, MatchCondition, ModuleLoadState, RuntimeState};
|
||||||
use bread_shared::now_unix_ms;
|
use bread_shared::now_unix_ms;
|
||||||
|
|
||||||
pub enum LuaMessage {
|
pub enum LuaMessage {
|
||||||
|
|
@ -275,6 +275,8 @@ impl LuaEngine {
|
||||||
.clear();
|
.clear();
|
||||||
|
|
||||||
self.install_api()?;
|
self.install_api()?;
|
||||||
|
self.load_device_rules()?;
|
||||||
|
self.load_profiles()?;
|
||||||
self.load_init_and_modules()?;
|
self.load_init_and_modules()?;
|
||||||
self.run_on_reload();
|
self.run_on_reload();
|
||||||
info!("lua runtime reloaded");
|
info!("lua runtime reloaded");
|
||||||
|
|
@ -515,8 +517,14 @@ impl LuaEngine {
|
||||||
|
|
||||||
let profile_tbl = self.lua.create_table()?;
|
let profile_tbl = self.lua.create_table()?;
|
||||||
let state_handle = self.state_handle.clone();
|
let state_handle = self.state_handle.clone();
|
||||||
|
let emit_tx = self.emit_tx.clone();
|
||||||
let activate_fn = self.lua.create_function(move |_lua, name: String| {
|
let activate_fn = self.lua.create_function(move |_lua, name: String| {
|
||||||
state_handle.set_profile(name.clone());
|
state_handle.set_profile(name.clone());
|
||||||
|
let _ = emit_tx.send(BreadEvent::new(
|
||||||
|
"bread.profile.activated",
|
||||||
|
AdapterSource::System,
|
||||||
|
serde_json::json!({ "name": name }),
|
||||||
|
));
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
profile_tbl.set("activate", activate_fn)?;
|
profile_tbl.set("activate", activate_fn)?;
|
||||||
|
|
@ -700,6 +708,13 @@ impl LuaEngine {
|
||||||
})?;
|
})?;
|
||||||
hyprland_tbl.set("keyword", keyword_fn)?;
|
hyprland_tbl.set("keyword", keyword_fn)?;
|
||||||
|
|
||||||
|
let eval_fn = self.lua.create_function(move |_lua, expr: String| {
|
||||||
|
let resp = hyprland_request(&format!("eval {expr}"))
|
||||||
|
.map_err(|e| LuaError::external(e.to_string()))?;
|
||||||
|
Ok(resp)
|
||||||
|
})?;
|
||||||
|
hyprland_tbl.set("eval", eval_fn)?;
|
||||||
|
|
||||||
let active_window_fn = self.lua.create_function(move |lua, ()| {
|
let active_window_fn = self.lua.create_function(move |lua, ()| {
|
||||||
let resp = hyprland_request("j/activewindow")
|
let resp = hyprland_request("j/activewindow")
|
||||||
.map_err(|e| LuaError::external(e.to_string()))?;
|
.map_err(|e| LuaError::external(e.to_string()))?;
|
||||||
|
|
@ -835,6 +850,11 @@ impl LuaEngine {
|
||||||
ModuleInfo { table_key: key },
|
ModuleInfo { table_key: key },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Register in package.loaded so require("bread.devices") etc. works
|
||||||
|
let package: Table = lua.globals().get("package")?;
|
||||||
|
let loaded: Table = package.get("loaded")?;
|
||||||
|
loaded.set(decl.name.clone(), module_tbl.clone())?;
|
||||||
|
|
||||||
Ok(module_tbl)
|
Ok(module_tbl)
|
||||||
})?;
|
})?;
|
||||||
bread.set("module", module_fn)?;
|
bread.set("module", module_fn)?;
|
||||||
|
|
@ -907,6 +927,98 @@ impl LuaEngine {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn load_device_rules(&self) -> Result<()> {
|
||||||
|
let devices_path = self
|
||||||
|
.entry_point
|
||||||
|
.parent()
|
||||||
|
.map(|p| p.join("devices.lua"))
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("devices.lua"));
|
||||||
|
|
||||||
|
if !devices_path.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let source = fs::read_to_string(&devices_path)
|
||||||
|
.map_err(|e| anyhow!("failed to read devices.lua: {e}"))?;
|
||||||
|
|
||||||
|
let rules_value: mlua::Value = self
|
||||||
|
.lua
|
||||||
|
.load(&source)
|
||||||
|
.set_name("devices.lua")
|
||||||
|
.eval()
|
||||||
|
.map_err(|e| anyhow!("devices.lua error: {e}"))?;
|
||||||
|
|
||||||
|
let mlua::Value::Table(tbl) = rules_value else {
|
||||||
|
return Err(anyhow!("devices.lua must return a table of rules"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut rules: Vec<DeviceRule> = Vec::new();
|
||||||
|
for pair in tbl.sequence_values::<mlua::Table>() {
|
||||||
|
let entry = pair.map_err(|e| anyhow!("devices.lua rule error: {e}"))?;
|
||||||
|
let device: String = entry.get("device").unwrap_or_default();
|
||||||
|
if device.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the rule has a `match` key, each entry in it is a separate condition (OR logic).
|
||||||
|
// Otherwise the rule table itself is the single condition.
|
||||||
|
let conditions: Vec<MatchCondition> =
|
||||||
|
if let Ok(mlua::Value::Table(match_tbl)) = entry.get::<_, mlua::Value>("match") {
|
||||||
|
match_tbl
|
||||||
|
.sequence_values::<mlua::Table>()
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.map(|t| parse_match_condition(&t))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
vec![parse_match_condition(&entry)]
|
||||||
|
};
|
||||||
|
|
||||||
|
if !conditions.is_empty() {
|
||||||
|
rules.push(DeviceRule { device, conditions });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.state_handle.set_device_rules(rules);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_profiles(&self) -> Result<()> {
|
||||||
|
let profiles_path = self
|
||||||
|
.entry_point
|
||||||
|
.parent()
|
||||||
|
.map(|p| p.join("profiles.lua"))
|
||||||
|
.unwrap_or_else(|| PathBuf::from("profiles.lua"));
|
||||||
|
|
||||||
|
if !profiles_path.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let path_str = profiles_path.to_string_lossy().to_string();
|
||||||
|
self.lua.globals().set("__profiles_path", path_str)?;
|
||||||
|
self.lua
|
||||||
|
.load(
|
||||||
|
r#"
|
||||||
|
local ok, result = pcall(loadfile, __profiles_path)
|
||||||
|
__profiles_path = nil
|
||||||
|
if ok and type(result) == "function" then
|
||||||
|
ok, result = pcall(result)
|
||||||
|
end
|
||||||
|
if ok and type(result) == "table" then
|
||||||
|
bread.on("bread.profile.activated", function(event)
|
||||||
|
local name = event.data and event.data.name
|
||||||
|
local fn = name and result[name]
|
||||||
|
if type(fn) == "function" then
|
||||||
|
fn(event)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.set_name("profiles.lua")
|
||||||
|
.exec()
|
||||||
|
.map_err(|e| anyhow!("profiles.lua error: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
fn load_init_and_modules(&self) -> Result<()> {
|
fn load_init_and_modules(&self) -> Result<()> {
|
||||||
self.load_lua_file(&self.entry_point, "init", false)?;
|
self.load_lua_file(&self.entry_point, "init", false)?;
|
||||||
|
|
||||||
|
|
@ -1796,24 +1908,18 @@ const BUILTIN_DEVICES: &str = r#"
|
||||||
local M = bread.module({ name = "bread.devices", version = "1.0.0" })
|
local M = bread.module({ name = "bread.devices", version = "1.0.0" })
|
||||||
|
|
||||||
local rules = {}
|
local rules = {}
|
||||||
local user_patterns = {} -- { { pattern = "...", class = "..." }, ... }
|
|
||||||
|
|
||||||
local function matches_rule(rule, event)
|
local function matches_rule(rule, event)
|
||||||
local class = rule.class
|
|
||||||
local when = rule.when
|
local when = rule.when
|
||||||
local data = event.data or {}
|
local data = event.data or {}
|
||||||
|
|
||||||
if when == "connected" and event.event ~= "bread.device.connected" then
|
if when == "connected" and not event.event:match("%.connected$") then
|
||||||
if not event.event:match("%.connected$") then
|
return false
|
||||||
return false
|
elseif when == "disconnected" and not event.event:match("%.disconnected$") then
|
||||||
end
|
return false
|
||||||
elseif when == "disconnected" and event.event ~= "bread.device.disconnected" then
|
|
||||||
if not event.event:match("%.disconnected$") then
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
if class and data.class ~= class then
|
if rule.device and data.device ~= rule.device then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -1832,55 +1938,15 @@ local function run_rule(rule, event)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Reclassify an event's data.class based on user-registered name patterns.
|
|
||||||
-- Called before rule matching so that user-registered patterns take effect
|
|
||||||
-- even for devices that the daemon classified as Unknown.
|
|
||||||
local function apply_user_patterns(event)
|
|
||||||
if not event.data then return event end
|
|
||||||
local name = tostring(event.data.name or ""):lower()
|
|
||||||
local vendor = tostring(event.data.vendor or ""):lower()
|
|
||||||
local combined = name .. " " .. vendor
|
|
||||||
for _, p in ipairs(user_patterns) do
|
|
||||||
if combined:find(p.pattern, 1, true) then
|
|
||||||
-- Return a shallow copy with the class overridden so we don't
|
|
||||||
-- mutate the original event that other handlers may receive.
|
|
||||||
local patched = {}
|
|
||||||
for k, v in pairs(event) do patched[k] = v end
|
|
||||||
patched.data = {}
|
|
||||||
for k, v in pairs(event.data) do patched.data[k] = v end
|
|
||||||
patched.data.class = p.class
|
|
||||||
return patched
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return event
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.on(opts)
|
function M.on(opts)
|
||||||
table.insert(rules, opts)
|
table.insert(rules, opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Register a user-defined device pattern so the daemon can correctly classify
|
|
||||||
-- hardware that the automatic classifier doesn't recognise.
|
|
||||||
--
|
|
||||||
-- Usage:
|
|
||||||
-- local devices = require("bread.devices")
|
|
||||||
-- devices.register("CalDigit", "dock")
|
|
||||||
-- devices.register("Keychron", "keyboard")
|
|
||||||
-- devices.register("MX Master", "mouse")
|
|
||||||
--
|
|
||||||
-- The pattern is matched case-insensitively against the device name and vendor
|
|
||||||
-- combined. The class must be one of: dock, keyboard, mouse, tablet, display,
|
|
||||||
-- storage, audio, unknown.
|
|
||||||
function M.register(pattern, class)
|
|
||||||
table.insert(user_patterns, { pattern = pattern:lower(), class = class })
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.on_load()
|
function M.on_load()
|
||||||
bread.on("bread.device.**", function(event)
|
bread.on("bread.device.**", function(event)
|
||||||
local patched = apply_user_patterns(event)
|
|
||||||
for _, rule in ipairs(rules) do
|
for _, rule in ipairs(rules) do
|
||||||
if matches_rule(rule, patched) then
|
if matches_rule(rule, event) then
|
||||||
run_rule(rule, patched)
|
run_rule(rule, event)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
@ -2018,13 +2084,28 @@ fn builtin_module_decls(disabled: &HashSet<String>) -> Vec<ModuleDecl> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hyprland_request_socket() -> Result<PathBuf> {
|
fn hyprland_request_socket() -> Result<PathBuf> {
|
||||||
let instance = std::env::var("HYPRLAND_INSTANCE_SIGNATURE")
|
|
||||||
.map_err(|_| anyhow!("HYPRLAND_INSTANCE_SIGNATURE is not set"))?;
|
|
||||||
let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
|
let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
|
||||||
Ok(PathBuf::from(runtime)
|
|
||||||
.join("hypr")
|
if let Ok(instance) = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") {
|
||||||
.join(instance)
|
return Ok(PathBuf::from(runtime)
|
||||||
.join(".socket.sock"))
|
.join("hypr")
|
||||||
|
.join(instance)
|
||||||
|
.join(".socket.sock"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let hypr_dir = PathBuf::from(&runtime).join("hypr");
|
||||||
|
let mut sockets: Vec<PathBuf> = std::fs::read_dir(&hypr_dir)
|
||||||
|
.map_err(|_| anyhow!("no Hyprland instance found ({})", hypr_dir.display()))?
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.path().join(".socket.sock"))
|
||||||
|
.filter(|p| p.exists())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
match sockets.len() {
|
||||||
|
0 => Err(anyhow!("no Hyprland instance found in {}", hypr_dir.display())),
|
||||||
|
1 => Ok(sockets.remove(0)),
|
||||||
|
_ => Ok(sockets.remove(0)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hyprland_request(request: &str) -> Result<String> {
|
fn hyprland_request(request: &str) -> Result<String> {
|
||||||
|
|
@ -2039,6 +2120,22 @@ fn hyprland_request(request: &str) -> Result<String> {
|
||||||
Ok(buffer)
|
Ok(buffer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_match_condition(tbl: &mlua::Table) -> MatchCondition {
|
||||||
|
MatchCondition {
|
||||||
|
vendor_id: tbl.get("vendor_id").ok(),
|
||||||
|
product_id: tbl.get("product_id").ok(),
|
||||||
|
name: tbl.get("name").ok(),
|
||||||
|
vendor: tbl.get("vendor").ok(),
|
||||||
|
name_contains: tbl.get("name_contains").ok(),
|
||||||
|
id_input_keyboard: tbl.get("id_input_keyboard").ok(),
|
||||||
|
id_input_mouse: tbl.get("id_input_mouse").ok(),
|
||||||
|
id_input_tablet: tbl.get("id_input_tablet").ok(),
|
||||||
|
usb_hub: tbl.get("usb_hub").ok(),
|
||||||
|
id_usb_class: tbl.get("id_usb_class").ok(),
|
||||||
|
subsystem: tbl.get("subsystem").ok(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn list_lua_files(root: &Path) -> Result<Vec<PathBuf>> {
|
fn list_lua_files(root: &Path) -> Result<Vec<PathBuf>> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
if !root.exists() {
|
if !root.exists() {
|
||||||
|
|
|
||||||
|
|
@ -2,35 +2,97 @@
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
INSTALL_PREFIX="${INSTALL_PREFIX:-/usr/bin}"
|
BIN_DIR="${BIN_DIR:-$HOME/.local/bin}"
|
||||||
SERVICE_DIR="${HOME}/.config/systemd/user"
|
SERVICE_DIR="${HOME}/.config/systemd/user"
|
||||||
|
CONFIG_DIR="${HOME}/.config/bread"
|
||||||
|
MODULES_DIR="${CONFIG_DIR}/modules"
|
||||||
|
|
||||||
# ── build ──────────────────────────────────────────────────────────────────────
|
# ── build ──────────────────────────────────────────────────────────────────────
|
||||||
echo "building bread (release)..."
|
echo "building bread (release)..."
|
||||||
cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml"
|
cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml"
|
||||||
|
echo ""
|
||||||
|
|
||||||
# ── install binaries ───────────────────────────────────────────────────────────
|
# ── symlinks ───────────────────────────────────────────────────────────────────
|
||||||
echo "installing binaries to $INSTALL_PREFIX (requires sudo)..."
|
echo "symlinking binaries into $BIN_DIR..."
|
||||||
sudo install -Dm755 "$REPO_ROOT/target/release/breadd" "$INSTALL_PREFIX/breadd"
|
mkdir -p "$BIN_DIR"
|
||||||
sudo install -Dm755 "$REPO_ROOT/target/release/bread" "$INSTALL_PREFIX/bread"
|
ln -sf "$REPO_ROOT/target/release/breadd" "$BIN_DIR/breadd"
|
||||||
echo " installed $INSTALL_PREFIX/breadd"
|
ln -sf "$REPO_ROOT/target/release/bread" "$BIN_DIR/bread"
|
||||||
echo " installed $INSTALL_PREFIX/bread"
|
echo " $BIN_DIR/breadd -> $REPO_ROOT/target/release/breadd"
|
||||||
|
echo " $BIN_DIR/bread -> $REPO_ROOT/target/release/bread"
|
||||||
|
|
||||||
|
if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then
|
||||||
|
echo ""
|
||||||
|
echo " note: $BIN_DIR is not in PATH — add to your shell profile:"
|
||||||
|
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── config ─────────────────────────────────────────────────────────────────────
|
||||||
|
echo "setting up config..."
|
||||||
|
mkdir -p "$CONFIG_DIR" "$MODULES_DIR"
|
||||||
|
|
||||||
|
if [[ ! -f "$CONFIG_DIR/breadd.toml" ]]; then
|
||||||
|
cat > "$CONFIG_DIR/breadd.toml" << 'EOF'
|
||||||
|
[daemon]
|
||||||
|
log_level = "info"
|
||||||
|
|
||||||
|
[lua]
|
||||||
|
entry_point = "~/.config/bread/init.lua"
|
||||||
|
module_path = "~/.config/bread/modules"
|
||||||
|
|
||||||
|
[adapters.hyprland]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[adapters.udev]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[adapters.power]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[adapters.network]
|
||||||
|
enabled = true
|
||||||
|
EOF
|
||||||
|
echo " created $CONFIG_DIR/breadd.toml"
|
||||||
|
else
|
||||||
|
echo " $CONFIG_DIR/breadd.toml already exists, skipping"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$CONFIG_DIR/init.lua" ]]; then
|
||||||
|
cat > "$CONFIG_DIR/init.lua" << 'EOF'
|
||||||
|
-- bread init.lua — loaded before modules, use for global setup
|
||||||
|
bread.log("bread started")
|
||||||
|
EOF
|
||||||
|
echo " created $CONFIG_DIR/init.lua"
|
||||||
|
else
|
||||||
|
echo " $CONFIG_DIR/init.lua already exists, skipping"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
# ── systemd user service ───────────────────────────────────────────────────────
|
# ── systemd user service ───────────────────────────────────────────────────────
|
||||||
echo "installing systemd user service..."
|
echo "installing systemd user service..."
|
||||||
mkdir -p "$SERVICE_DIR"
|
mkdir -p "$SERVICE_DIR"
|
||||||
install -Dm644 "$REPO_ROOT/packaging/systemd/breadd.service" "$SERVICE_DIR/breadd.service"
|
# Patch ExecStart to match the actual install location rather than hardcoding /usr/bin.
|
||||||
echo " installed $SERVICE_DIR/breadd.service"
|
sed "s|ExecStart=.*|ExecStart=$BIN_DIR/breadd|" \
|
||||||
|
"$REPO_ROOT/packaging/systemd/breadd.service" \
|
||||||
|
> "$SERVICE_DIR/breadd.service"
|
||||||
|
echo " installed $SERVICE_DIR/breadd.service (ExecStart=$BIN_DIR/breadd)"
|
||||||
|
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
systemctl --user enable --now breadd
|
|
||||||
echo " breadd enabled and started"
|
if systemctl --user is-active --quiet breadd 2>/dev/null; then
|
||||||
|
systemctl --user restart breadd
|
||||||
|
echo " breadd restarted"
|
||||||
|
else
|
||||||
|
systemctl --user enable --now breadd
|
||||||
|
echo " breadd enabled and started"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
# ── verify ─────────────────────────────────────────────────────────────────────
|
# ── verify ─────────────────────────────────────────────────────────────────────
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
if bread ping &>/dev/null; then
|
if "$BIN_DIR/bread" ping &>/dev/null; then
|
||||||
echo ""
|
"$BIN_DIR/bread" doctor
|
||||||
bread doctor
|
|
||||||
else
|
else
|
||||||
echo "warning: daemon did not respond to ping — check: journalctl --user -u breadd -n 20"
|
echo "warning: daemon did not respond to ping"
|
||||||
|
echo " check: journalctl --user -u breadd -n 20"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue