Merge pull request #5 from Breadway/dev

Enhance device normalization and classification with Lua support
This commit is contained in:
Breadway 2026-05-12 21:31:15 +08:00 committed by GitHub
commit aa967fda8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1133 additions and 674 deletions

32
.gitignore vendored
View file

@ -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

View file

@ -6,6 +6,8 @@
- [Getting started](#getting-started) - [Getting started](#getting-started)
- [Your first module](#your-first-module) - [Your first module](#your-first-module)
- [Run, reload, and watch](#run-reload-and-watch) - [Run, reload, and watch](#run-reload-and-watch)
- [Modules: install and manage](#modules-install-and-manage)
- [Sync: snapshot and restore](#sync-snapshot-and-restore)
- [Debugging tips](#debugging-tips) - [Debugging tips](#debugging-tips)
- [Dictionary: Lua API](#dictionary-lua-api) - [Dictionary: Lua API](#dictionary-lua-api)
- [Dictionary: Built-in modules](#dictionary-built-in-modules) - [Dictionary: Built-in modules](#dictionary-built-in-modules)
@ -15,11 +17,11 @@
## Overview ## Overview
Bread is a reactive automation fabric for Linux desktops. The daemon (`bread`) normalizes external signals into semantic events, maintains runtime state, and dispatches events to Lua modules that implement automation. Bread is a reactive automation fabric for Linux desktops. The daemon (`breadd`) normalizes external signals into semantic events, maintains runtime state, and dispatches events to Lua modules that implement automation.
- Daemon: long-running Rust process, source of truth for runtime state - **Daemon** (`breadd`) — long-running Rust process; source of truth for runtime state
- Lua runtime: dedicated thread inside the daemon; automation logic lives here - **Lua runtime** dedicated thread inside the daemon; automation logic lives here
- CLI: talks to the daemon over a Unix socket - **CLI** (`bread`) — talks to the daemon over a Unix socket
If you are new to Bread, start with the quick walkthrough below, then jump to the full dictionary when you need exact API details. If you are new to Bread, start with the quick walkthrough below, then jump to the full dictionary when you need exact API details.
@ -27,75 +29,189 @@ If you are new to Bread, start with the quick walkthrough below, then jump to th
### 1) Create a minimal config ### 1) Create a minimal config
- Daemon config: `~/.config/bread/bread.toml` - Daemon config: `~/.config/bread/breadd.toml` (all values optional)
- Lua entry point: `~/.config/bread/init.lua` - Lua entry point: `~/.config/bread/init.lua`
- Lua modules: `~/.config/bread/modules/` - Lua modules: `~/.config/bread/modules/`
### 2) Minimal `init.lua` ### 2) Minimal `init.lua`
```lua ```lua
require("modules.devices") bread.on("bread.system.startup", function(event)
require("modules.workspaces")
bread.on("bread.system.startup", function()
bread.profile.activate("default") bread.profile.activate("default")
bread.log("bread started on " .. bread.machine.name())
end) end)
``` ```
### 3) Add your first module ### 3) Start the daemon
Create a Lua file under your modules directory and load it from `init.lua`. ```bash
systemctl --user start breadd
# Or directly:
breadd
```
### 4) Check that it's running
```bash
bread ping
bread doctor
```
## Your first module ## Your first module
Create a file at `~/.config/bread/modules/hello.lua`. It is discovered and loaded automatically after `init.lua`.
```lua ```lua
local M = bread.module({ name = "hello", version = "0.1.0" }) local M = bread.module({ name = "hello", version = "0.1.0" })
function M.on_load() function M.on_load()
bread.log("hello from bread") bread.log("hello from bread on " .. bread.machine.name())
bread.on("bread.device.*", function(event) bread.on("bread.device.*", function(event)
bread.log(event.event) bread.log("device event: " .. event.event)
end) end)
end end
return M return M
``` ```
Why this shape? Key rules:
- Every module must call `bread.module` once. - Every module must call `bread.module` exactly once at the top level.
- `on_load` is a good place to register subscriptions. - Register subscriptions inside `M.on_load` so they are cleaned up properly on hot reload.
- Use `bread.log` early to verify handlers are firing. - Use `bread.log` early to verify handlers are firing.
## Run, reload, and watch ## Run, reload, and watch
- Start the daemon, then use `bread reload` after editing Lua. ```bash
- `bread reload --watch` will keep reloading on changes. # Hot-reload the Lua runtime after editing config
- See [Examples.md](Examples.md) for real-world ports. bread reload
# Watch for file changes and reload automatically
bread reload --watch
```
If any module fails to load, `bread reload` prints the error with a full Lua stack trace. The daemon stays running — fix the file and reload again.
## Modules: install and manage
Modules are Lua packages installed to `~/.config/bread/modules/`. The CLI manages the install lifecycle.
```bash
# Install from GitHub (downloads and extracts the default branch tarball)
bread modules install github:someuser/bread-wifi
# Install from a local directory
bread modules install ~/src/my-module
# Install a specific ref
bread modules install github:someuser/bread-wifi@v1.2.0
# List installed modules and their daemon status
bread modules list
# Show full manifest for one module
bread modules info bread-wifi
# Re-install all GitHub-sourced modules (pick up upstream changes)
bread modules update
# Remove a module
bread modules remove bread-wifi
bread modules remove bread-wifi --yes # skip confirmation
```
Each installed module has a `bread.module.toml` manifest:
```toml
name = "wifi"
version = "1.0.0"
description = "WiFi management for Bread"
author = "someuser"
source = "github:someuser/bread-wifi"
installed_at = "2026-01-01T00:00:00Z"
```
## Sync: snapshot and restore
Bread sync snapshots your Bread config, arbitrary dotfiles, and installed package lists into a Git repository. Pull it on another machine to restore state.
```bash
# First-time setup
bread sync init --remote git@github.com:you/bread-config.git
# Snapshot and push
bread sync push
# On another machine: pull and apply
bread sync pull
# Also reinstall packages from snapshot
bread sync pull --install-packages
# See what has changed
bread sync status
bread sync diff
bread sync diff --remote
# List known machines
bread sync machines
```
Configure sync in `~/.config/bread/sync.toml`:
```toml
[remote]
url = "git@github.com:you/bread-config.git"
branch = "main"
[machine]
name = "hermes"
tags = ["laptop", "battery"]
[packages]
enabled = true
managers = ["pacman", "pip", "cargo"]
[delegates]
include = ["~/.config/nvim", "~/.config/waybar"]
exclude = ["**/.git", "**/*.cache"]
```
The sync repo stores:
```
~/.local/share/bread/sync-repo/
├── bread/ ← ~/.config/bread/ snapshot
├── configs/ ← delegate paths (nvim, waybar, etc.)
├── machines/ ← per-machine profiles with tags and last-sync time
└── packages/ ← package snapshots (pacman.txt, pip.txt, etc.)
```
## Debugging tips ## Debugging tips
- Log event payloads with `bread.log(event.data.raw)` when matching devices. - Run `bread events` to see live normalized events.
- Use `bread.events` in the CLI to see live normalized events. - Run `bread state` to see full runtime state as JSON.
- Use `bread state` to see runtime state as JSON. - Run `bread doctor` to check adapter and module health.
- Log event payloads with `bread.log(tostring(event.data))`.
- Use `RUST_LOG=debug breadd` for verbose daemon output.
## Lua module system ---
### Entry point and module scanning ## Dictionary: Lua API
- `init.lua` is executed first. Every API is exposed through the `bread` global table.
- Modules are discovered by scanning `~/.config/bread/modules/` for `.lua` files.
- Every module must call `bread.module` exactly once at top-level.
- Modules are ordered by the `after` dependency list.
### Module declaration ### Module declaration
Every module must call `bread.module` exactly once at the top level.
```lua ```lua
local M = bread.module({ local M = bread.module({
name = "my.module", name = "my.module",
version = "0.1.0", version = "0.1.0",
after = { "bread.devices" }, after = { "bread.devices" }, -- optional: load after this module
}) })
return M return M
@ -103,65 +219,25 @@ return M
If a module does not call `bread.module`, it fails to load and is marked as a load error. If a module does not call `bread.module`, it fails to load and is marked as a load error.
### Require loader
`require("bread.<path>")` resolves to a Lua file under the module path. For example:
```lua
local utils = require("bread.lib.utils")
```
This loads `~/.config/bread/modules/lib/utils.lua` if it exists. Non-`bread.*` `require` calls fall back to standard Lua behavior.
### Lifecycle hooks
Modules may export any of the following hooks. All are optional.
```lua
function M.on_load()
-- register subscriptions, initialize module state
end
function M.on_reload()
-- called after a hot reload completes
end
function M.on_unload()
-- called before the Lua instance is dropped
end
function M.on_error(err)
-- called when a handler throws
-- return true to keep the subscription, false to cancel it
return true
end
```
### Module storage
Each module has a scoped key-value store that survives reloads:
```lua
M.store.set("last_profile", "docked")
local value = M.store.get("last_profile")
```
The store lives in the daemon runtime state and is not shared across modules.
## Dictionary: Lua API
Every API is exposed through the `bread` global table.
### Events ### Events
#### `bread.on(pattern, fn) -> id` #### `bread.on(pattern, fn) -> id`
Subscribe to matching events. Returns a numeric subscription ID. Subscribe to matching events. Returns a numeric subscription ID.
```lua
local id = bread.on("bread.device.*", function(event)
-- event.event → the full event name string
-- event.data → table of event-specific fields
-- event.source → adapter that produced it ("Udev", "Hyprland", etc.)
bread.log(event.event)
end)
```
#### `bread.once(pattern, fn) -> id` #### `bread.once(pattern, fn) -> id`
Subscribe once. The handler is removed after the first match. Subscribe once. The handler is removed after the first match.
#### `bread.filter(pattern, fn, opts) -> id` #### `bread.filter(pattern, fn, opts) -> id`
Subscribe with a predicate filter. `opts` must contain `filter`: Subscribe with a predicate. `opts` must contain a `filter` function:
```lua ```lua
bread.filter("bread.device.*", function(event) bread.filter("bread.device.*", function(event)
@ -174,13 +250,13 @@ end, {
``` ```
#### `bread.off(id)` #### `bread.off(id)`
Unsubscribe an event or state watch by ID. Unsubscribe an event handler or state watch by ID.
#### `bread.emit(event, data)` #### `bread.emit(event, data)`
Emit a custom event into the system pipeline. Emit a custom event into the system pipeline. Useful for cross-module communication.
#### `bread.wait(pattern, opts) -> event | nil` #### `bread.wait(pattern, opts) -> event | nil`
Coroutine-only helper that waits for a matching event. Coroutine-only helper that suspends until a matching event arrives.
```lua ```lua
bread.spawn(function() bread.spawn(function()
@ -192,30 +268,37 @@ end)
``` ```
#### `bread.spawn(fn)` #### `bread.spawn(fn)`
Spawn a coroutine and surface errors if the coroutine fails. Spawn a coroutine and surface errors if it fails. Required for using `bread.wait`.
### State ### State
#### `bread.state.get(path)` #### `bread.state.get(path)`
Read a state subtree by dotted path (e.g. `"network.online"`). Read a state subtree by dotted path.
#### Convenience helpers ```lua
local monitors = bread.state.get("monitors")
local online = bread.state.get("network.online")
```
- `bread.state.monitors()` #### Typed shorthands
- `bread.state.active_workspace()`
- `bread.state.active_window()` ```lua
- `bread.state.devices()` bread.state.monitors()
- `bread.state.power()` bread.state.active_workspace()
- `bread.state.network()` bread.state.active_window()
- `bread.state.profile()` bread.state.devices()
bread.state.power()
bread.state.network()
bread.state.profile()
```
#### `bread.state.watch(path, fn) -> id` #### `bread.state.watch(path, fn) -> id`
Watch a state path. The callback receives `(new, old)`. Watch a state path for changes. The callback receives `(new_value, old_value)`.
```lua ```lua
bread.state.watch("power.ac_connected", function(new_val, old_val) bread.state.watch("power.ac_connected", function(new_val, old_val)
if new_val then if new_val then
bread.exec("notify-send 'AC connected'") bread.notify("AC connected")
end end
end) end)
``` ```
@ -223,66 +306,147 @@ end)
### Profiles ### Profiles
#### `bread.profile.activate(name)` #### `bread.profile.activate(name)`
Update the active profile. The CLI also emits `bread.profile.activated` over IPC; the Lua API does not emit this event on its own. Activate a named profile. Emits `bread.profile.activated` over IPC.
### Execution ### Execution
#### `bread.exec(cmd)` #### `bread.exec(cmd)`
Runs `cmd` in a `sh -lc` shell. Fire-and-forget (async). Run a shell command. Fire-and-forget (async, does not block Lua).
### Notifications ### Notifications
#### `bread.notify(message, opts)` #### `bread.notify(message, opts)`
Sends a desktop notification via `notify-send`. Send a desktop notification via `notify-send`.
Options: Options:
- `title` (string, default: `"bread"`) | Key | Type | Default |
- `urgency` (string, default from config) |-----|------|---------|
- `timeout` (ms, default from config) | `title` | string | `"bread"` |
- `icon` (string, optional) | `urgency` | string | from config |
| `timeout` | ms | from config |
| `icon` | string | none |
Calling `bread.notify` emits `bread.notify.sent` with `{ title, message, urgency }`. Calling `bread.notify` emits `bread.notify.sent` with `{ title, message, urgency }`.
### Timers ### Timers
#### `bread.after(delay_ms, fn) -> id` #### `bread.after(delay_ms, fn) -> id`
Run once after delay. Run once after a delay.
#### `bread.every(interval_ms, fn) -> id` #### `bread.every(interval_ms, fn) -> id`
Run repeatedly on an interval. Run on a repeating interval.
#### `bread.cancel(id)` #### `bread.cancel(id)`
Cancel a timer created by `after` or `every`. Cancel a timer created by `after` or `every`. Timers are also cancelled automatically on reload.
### Utilities ### Utilities
#### `bread.debounce(delay_ms, fn) -> wrapped_fn` #### `bread.debounce(delay_ms, fn) -> wrapped_fn`
Returns a wrapper that only fires after quiet time. Returns a wrapper that fires only after `delay_ms` of quiet time.
```lua
local fn = bread.debounce(200, function(event)
reconfigure_monitors()
end)
bread.on("bread.monitor.**", fn)
```
#### `bread.log(msg)` / `bread.warn(msg)` / `bread.error(msg)` #### `bread.log(msg)` / `bread.warn(msg)` / `bread.error(msg)`
Log helpers that accept any Lua value. Logging helpers. Accept any Lua value (coerced via `tostring`).
### Machine and filesystem
#### `bread.machine.name() -> string`
Returns the machine name from `sync.toml`. Falls back to the system hostname if sync is not initialized.
#### `bread.machine.tags() -> string[]`
Returns the tags array from `sync.toml`, or `{}` if sync is not initialized.
#### `bread.machine.has_tag(tag) -> bool`
Returns true if the machine has the given tag.
#### `bread.fs.write(path, content)`
Write a file. Creates parent directories as needed. `~` is expanded.
#### `bread.fs.read(path) -> string | nil`
Read a file. Returns `nil` if the file does not exist. `~` is expanded.
#### `bread.fs.exists(path) -> bool`
Returns true if the path exists. `~` is expanded.
#### `bread.fs.expand(path) -> string`
Expand `~` to the home directory.
### Hyprland ### Hyprland
The `bread.hyprland` namespace provides compositor bindings: The `bread.hyprland` namespace provides compositor bindings.
- `bread.hyprland.dispatch(cmd, args)` ```lua
- `bread.hyprland.keyword(key, value)` -- Dispatch a Hyprland command
- `bread.hyprland.active_window()` bread.hyprland.dispatch("workspace", "2")
- `bread.hyprland.monitors()` bread.hyprland.dispatch("exec", "kitty")
- `bread.hyprland.workspaces()`
- `bread.hyprland.clients()`
- `bread.hyprland.on_raw(kind, fn) -> id`
`bread.hyprland.on_raw` filters raw Hyprland events by `kind` and delivers the full event payload (including the original raw string). -- Set a keyword
bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1")
-- Query compositor state (returns deserialized Lua tables)
local win = bread.hyprland.active_window()
local monitors = bread.hyprland.monitors()
local workspaces = bread.hyprland.workspaces()
local clients = bread.hyprland.clients()
-- Subscribe to raw Hyprland events (bypasses normalization)
bread.hyprland.on_raw("activewindow", function(raw)
-- raw payload includes: kind, raw (original string), data
end)
```
### Module lifecycle hooks
All hooks are optional.
```lua
function M.on_load()
-- Called after the module loads. Register subscriptions here.
end
function M.on_reload()
-- Called after a hot reload completes across all modules.
end
function M.on_unload()
-- Called before the Lua instance is dropped.
end
function M.on_error(err)
-- Called when a subscription handler in this module throws.
-- Return true to keep the subscription alive, false to cancel it.
return true
end
```
### Module storage
Survives hot reload; does not survive daemon restart.
```lua
M.store.set("last_profile", "docked")
local value = M.store.get("last_profile")
```
Storage is scoped per module and is not shared across modules.
---
## Dictionary: Built-in modules ## Dictionary: Built-in modules
Built-ins are enabled by default. Disable them via `[modules].disable` in the config. Built-ins are loaded before user modules. Disable them via `[modules].disable` in the daemon config.
### `bread.monitors` ### `bread.monitors`
High-level declarative monitor event handlers.
```lua ```lua
local monitors = require("bread.monitors") local monitors = require("bread.monitors")
@ -297,35 +461,125 @@ monitors.on({
}) })
``` ```
- `monitors.on({ when, monitors, run })` | Function | Description |
- `monitors.layout(name, fn)` |----------|-------------|
- `monitors.apply(name) -> fn` | `M.on(opts)` | Register a monitor workflow. `opts`: `when`, `monitors` (optional list), `run` (function or shell string) |
| `M.layout(name, fn)` | Register a named layout function |
| `M.apply(name) -> fn` | Returns a function that calls the named layout |
`when` is one of `connected`, `disconnected`, `changed`. `run` may be a function or a shell command string. `when` is one of `connected`, `disconnected`, `changed`.
### `bread.devices` ### `bread.devices`
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")
devices.register("Keychron", "keyboard") devices.on({
when = "connected",
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 = "~/.config/bread/scripts/dock-connected.sh"
})
devices.on({
when = "disconnected",
name = "CalDigit", -- pattern-matched against event.data.name
run = function(event) 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,
}) })
``` ```
- `devices.on({ when, class, name, run })` #### Example: Dock-specific setup
- `devices.register(pattern, class)`
`class` may be `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`
Workspace-to-monitor assignment and app pinning.
```lua ```lua
local workspaces = require("bread.workspaces") local workspaces = require("bread.workspaces")
@ -333,12 +587,16 @@ workspaces.assign("1", "HDMI-A-1")
workspaces.pin({ app = "Firefox", workspace = "2" }) workspaces.pin({ app = "Firefox", workspace = "2" })
``` ```
- `workspaces.assign(workspace, monitor)` | Function | Description |
- `workspaces.pin({ app, workspace })` |----------|-------------|
- `workspaces.apply_assignments()` | `M.assign(workspace, monitor)` | Assign a workspace to a monitor |
| `M.pin(opts)` | Pin an app class to a workspace. `opts`: `app`, `workspace` |
| `M.apply_assignments()` | Apply all registered assignments via Hyprland dispatch |
### `bread.binds` ### `bread.binds`
Runtime keybind management via Hyprland.
```lua ```lua
local binds = require("bread.binds") local binds = require("bread.binds")
@ -350,9 +608,13 @@ binds.add({
}) })
``` ```
- `binds.add({ mods, key, dispatch, args })` | Function | Description |
- `binds.remove(key)` |----------|-------------|
- `binds.replace(key, opts)` | `M.add(opts)` | Add a keybind. `opts`: `mods`, `key`, `dispatch`, `args` |
| `M.remove(key)` | Remove a keybind by key |
| `M.replace(key, opts)` | Remove and re-add a keybind |
---
## Dictionary: Event reference ## Dictionary: Event reference
@ -369,103 +631,136 @@ Events are delivered as a `BreadEvent`:
### Pattern matching ### Pattern matching
Patterns match event names with glob-style syntax: | Pattern | Matches |
|---------|---------|
- Exact match: `bread.device.dock.connected` | `bread.device.dock.connected` | Exact match only |
- `*` matches within a single segment (does not cross `.`) | `bread.device.*` | One segment wildcard (does not cross `.`) |
- `**` matches across segments (recursive) | `bread.device.**` | Any depth under `bread.device` |
- `?` matches a single character within a segment | `bread.monitor.?` | Single character within one segment |
Examples:
```lua
bread.on("bread.device.*", handler)
bread.on("bread.device.**", handler)
bread.on("bread.monitor.?", handler)
```
### Normalized events ### Normalized events
#### System #### System
- `bread.system.startup` (data: `{}`) | Event | Data |
|-------|------|
| `bread.system.startup` | `{}` |
#### Devices (udev) #### Devices (udev)
- `bread.device.connected` | Event | Data |
- `bread.device.disconnected` |-------|------|
- `bread.device.changed` | `bread.device.connected` | `{ id, device, name, vendor, vendor_id, product_id, subsystem, raw }` |
- `bread.device.<class>.connected` | `bread.device.disconnected` | same |
- `bread.device.<class>.disconnected` | `bread.device.<device>.connected` | `{ id, device }` |
- `bread.device.<class>.changed` | `bread.device.<device>.disconnected` | `{ id, device }` |
Payload notes: `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`.
- Device events include `id` and `class`; the generic event also includes `raw`.
- `<class>` is one of: `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`.
#### Hyprland #### Hyprland
- `bread.workspace.changed` (raw payload) | Event | Data |
- `bread.workspace.created` (`{ "workspace": "..." }`) |-------|------|
- `bread.workspace.destroyed` (`{ "workspace": "..." }`) | `bread.workspace.changed` | raw payload |
- `bread.monitor.connected` (raw payload) | `bread.workspace.created` | `{ workspace }` |
- `bread.monitor.disconnected` (raw payload) | `bread.workspace.destroyed` | `{ workspace }` |
- `bread.window.focus.changed` (raw payload) | `bread.monitor.connected` | raw payload |
- `bread.window.focused` (`{ "address": "..." }`) | `bread.monitor.disconnected` | raw payload |
- `bread.window.opened` (`{ "address", "workspace", "class", "title" }`) | `bread.window.focus.changed` | raw payload |
- `bread.window.closed` (`{ "address": "..." }`) | `bread.window.focused` | `{ address }` |
- `bread.window.moved` (`{ "address", "workspace" }`) | `bread.window.opened` | `{ address, workspace, class, title }` |
- `bread.hyprland.event` (raw payload for unhandled kinds) | `bread.window.closed` | `{ address }` |
| `bread.window.moved` | `{ address, workspace }` |
Raw Hyprland payloads contain `kind`, `raw`, and `data` fields. | `bread.hyprland.event` | `{ kind, raw, data }` (unhandled kinds) |
#### Power #### Power
- `bread.power.ac.connected` | Event | Data |
- `bread.power.ac.disconnected` |-------|------|
- `bread.power.battery.low` | `bread.power.ac.connected` | `{ ac_connected, battery_percent }` |
- `bread.power.battery.very_low` | `bread.power.ac.disconnected` | `{ ac_connected, battery_percent }` |
- `bread.power.battery.critical` | `bread.power.battery.low` | `{ battery_percent }` |
- `bread.power.battery.full` | `bread.power.battery.very_low` | `{ battery_percent }` |
- `bread.power.changed` (fallback) | `bread.power.battery.critical` | `{ battery_percent }` |
| `bread.power.battery.full` | `{ battery_percent }` |
Payload includes `ac_connected` and `battery_percent`. | `bread.power.changed` | `{ ac_connected, battery_percent }` |
#### Network #### Network
- `bread.network.connected` | Event | Data |
- `bread.network.disconnected` |-------|------|
| `bread.network.connected` | `{ online, interfaces }` |
| `bread.network.disconnected` | `{ online, interfaces }` |
Payload includes `online` and `interfaces`. #### System events
#### Other system events | Event | Data |
|-------|------|
| `bread.profile.activated` | `{ name }` |
| `bread.notify.sent` | `{ title, message, urgency }` |
| `bread.state.changed.<path>` | emitted by state watches |
- `bread.profile.activated` (emitted by IPC profile activation) ---
- `bread.notify.sent` (emitted by `bread.notify`)
- `bread.state.changed.<path>` (emitted when a state watch fires)
## Dictionary: Runtime state schema ## Dictionary: Runtime state schema
`bread.state.get("")` returns the full `RuntimeState`: `bread state` and `bread.state.get("")` return the full `RuntimeState`:
```json ```json
{ {
"monitors": [ { "name": "HDMI-A-1", "connected": true } ], "monitors": [
"workspaces": [ { "id": "1", "monitor": "HDMI-A-1" } ], { "name": "HDMI-A-1", "connected": true, "resolution": null, "position": null }
],
"workspaces": [
{ "id": "1", "monitor": "HDMI-A-1" }
],
"active_workspace": "1", "active_workspace": "1",
"active_window": "Firefox", "active_window": "0x...",
"devices": { "connected": [] }, "devices": {
"network": { "interfaces": {}, "online": false }, "connected": [
"power": { "ac_connected": false, "battery_percent": null, "battery_low": false }, {
"profile": { "active": "default", "history": [], "profiles": {} }, "id": "/sys/...",
"modules": [ { "name": "bread.devices", "status": "loaded", "last_error": null, "builtin": true, "store": {} } ] "name": "CalDigit TS4",
"device": "dock",
"subsystem": "usb",
"vendor_id": "0x35f5",
"product_id": "0x0104"
}
]
},
"network": {
"interfaces": { "eth0": { "up": true } },
"online": true
},
"power": {
"ac_connected": true,
"battery_percent": 87,
"battery_low": false
},
"profile": {
"active": "default",
"history": [],
"profiles": {}
},
"modules": [
{
"name": "bread.monitors",
"status": "loaded",
"last_error": null,
"builtin": true,
"store": {}
}
]
} }
``` ```
`status` values: `loaded`, `load_error`, `not_found`, `degraded`, `disabled`.
---
## Dictionary: IPC protocol ## Dictionary: IPC protocol
The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/bread.sock`. Messages are newline-delimited JSON. The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. Messages are newline-delimited JSON.
Request: Request:
@ -481,16 +776,17 @@ Response:
Available methods: Available methods:
- `ping` | Method | Params | Description |
- `health` |--------|--------|-------------|
- `state.get` | `ping` | — | Connectivity check |
- `state.dump` | `health` | — | Version, uptime, PID, adapter status |
- `modules.list` | `state.get` | `key` (dotted path) | Read a value from `RuntimeState` |
- `modules.reload` | `state.dump` | — | Return the full `RuntimeState` as JSON |
- `profile.list` | `modules.list` | — | List all loaded modules and their status |
- `profile.activate` | `modules.reload` | — | Hot-reload the Lua runtime |
- `events.subscribe` | `profile.list` | — | List defined profiles |
- `events.replay` | `profile.activate` | `name` | Switch active profile |
- `emit` | `events.subscribe` | — | Upgrade to streaming mode; pushes events line by line |
| `events.replay` | `since_ms` | Replay buffered events from the last N ms |
`events.subscribe` upgrades the socket to streaming mode and sends events as they occur. | `emit` | `event`, `data` | Inject a synthetic event into the pipeline |
| `sync.status` | — | Return sync init state: `{ initialized, machine?, remote? }` |

View file

@ -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)

View file

@ -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" => {

View file

@ -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"

View file

@ -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)
// If the env var is set, use it directly.
if let Ok(instance) = env::var("HYPRLAND_INSTANCE_SIGNATURE") {
return Ok(PathBuf::from(runtime)
.join("hypr") .join("hypr")
.join(instance) .join(instance)
.join(".socket2.sock")) .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) {

View file

@ -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,68 +46,66 @@ 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 &current_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 mut pfd = libc::pollfd {
fd,
events: libc::POLLIN,
revents: 0,
};
let ret = unsafe { libc::poll(&mut pfd, 1, 1000) };
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(());
}
}
}
}
})
.await??;
Ok(())
}
fn build_event(event: &udev::Event) -> RawEvent {
let action = event let action = event
.action() .action()
.map(|a| a.to_string_lossy().to_string()) .map(|a| a.to_string_lossy().to_string())
@ -128,12 +120,9 @@ async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -
.map(|v| v.to_string_lossy().to_string()) .map(|v| v.to_string_lossy().to_string())
.or_else(|| event.devnode().map(|n| n.display().to_string())) .or_else(|| event.devnode().map(|n| n.display().to_string()))
.unwrap_or_else(|| "unknown".to_string()); .unwrap_or_else(|| "unknown".to_string());
let id = event let id = event.syspath().to_string_lossy().to_string();
.syspath()
.to_string_lossy()
.to_string();
let msg = RawEvent { RawEvent {
source: AdapterSource::Udev, source: AdapterSource::Udev,
kind: "udev.change".to_string(), kind: "udev.change".to_string(),
payload: json!({ payload: json!({
@ -141,33 +130,22 @@ async fn run_udev_monitor(subsystems: Vec<String>, tx: mpsc::Sender<RawEvent>) -
"id": id, "id": id,
"name": name, "name": name,
"subsystem": subsystem, "subsystem": subsystem,
"id_input_keyboard": prop_bool(&event, "ID_INPUT_KEYBOARD"), "id_input_keyboard": prop_bool(event, "ID_INPUT_KEYBOARD"),
"id_input_mouse": prop_bool(&event, "ID_INPUT_MOUSE"), "id_input_mouse": prop_bool(event, "ID_INPUT_MOUSE"),
"id_input_joystick": prop_bool(&event, "ID_INPUT_JOYSTICK"), "id_input_joystick": prop_bool(event, "ID_INPUT_JOYSTICK"),
"id_input_touchpad": prop_bool(&event, "ID_INPUT_TOUCHPAD"), "id_input_touchpad": prop_bool(event, "ID_INPUT_TOUCHPAD"),
"id_input_tablet": prop_bool(&event, "ID_INPUT_TABLET"), "id_input_tablet": prop_bool(event, "ID_INPUT_TABLET"),
"id_usb_class": prop_str(&event, "ID_USB_CLASS"), "id_usb_class": prop_str(event, "ID_USB_CLASS"),
"id_usb_interfaces": prop_str(&event, "ID_USB_INTERFACES"), "id_usb_interfaces": prop_str(event, "ID_USB_INTERFACES"),
"id_vendor": prop_str(&event, "ID_VENDOR"), "id_vendor": prop_str(event, "ID_VENDOR"),
"id_model": prop_str(&event, "ID_MODEL"), "id_model": prop_str(event, "ID_MODEL"),
"vendor_id": prop_str(&event, "ID_VENDOR_ID"), "vendor_id": prop_str(event, "ID_VENDOR_ID"),
"product_id": prop_str(&event, "ID_MODEL_ID"), "product_id": prop_str(event, "ID_MODEL_ID"),
}), }),
timestamp: now_unix_ms(), timestamp: now_unix_ms(),
};
if tx.blocking_send(msg).is_err() {
break;
} }
} }
Ok(())
})
.await??;
Ok(())
}
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)

View file

@ -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
}

View file

@ -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;
}; };
if let StateCommand::SetDeviceRules(rules) = cmd {
device_rules = rules;
} else {
handle_command(cmd, &state, &mut subscriptions, &mut watches, &subscription_count).await; 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)

View file

@ -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)]

View file

@ -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
end elseif when == "disconnected" and not event.event:match("%.disconnected$") then
elseif when == "disconnected" and event.event ~= "bread.device.disconnected" then
if not event.event:match("%.disconnected$") then
return false 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)
if let Ok(instance) = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") {
return Ok(PathBuf::from(runtime)
.join("hypr") .join("hypr")
.join(instance) .join(instance)
.join(".socket.sock")) .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() {

View file

@ -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
if systemctl --user is-active --quiet breadd 2>/dev/null; then
systemctl --user restart breadd
echo " breadd restarted"
else
systemctl --user enable --now breadd systemctl --user enable --now breadd
echo " breadd enabled and started" 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