Release 1.0

This commit is contained in:
Breadway 2026-05-11 11:56:03 +08:00
parent 009ea6da0e
commit 730a8b61d7
32 changed files with 6629 additions and 0 deletions

228
breadd/src/core/config.rs Normal file
View file

@ -0,0 +1,228 @@
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::Result;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
#[serde(default)]
pub daemon: DaemonConfig,
#[serde(default)]
pub lua: LuaConfig,
#[serde(default)]
pub adapters: AdaptersConfig,
#[serde(default)]
pub events: EventsConfig,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DaemonConfig {
#[serde(default = "default_log_level")]
pub log_level: String,
#[serde(default)]
pub socket_path: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LuaConfig {
#[serde(default = "default_lua_entry")]
pub entry_point: String,
#[serde(default = "default_lua_modules")]
pub module_path: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AdaptersConfig {
#[serde(default)]
pub hyprland: AdapterToggle,
#[serde(default)]
pub udev: UdevConfig,
#[serde(default)]
pub power: PowerConfig,
#[serde(default)]
pub network: AdapterToggle,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AdapterToggle {
#[serde(default = "default_true")]
pub enabled: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct UdevConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_udev_subsystems")]
pub subsystems: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PowerConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_poll_interval")]
pub poll_interval_secs: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct EventsConfig {
#[serde(default = "default_dedup_window")]
pub dedup_window_ms: u64,
}
impl Default for Config {
fn default() -> Self {
Self {
daemon: DaemonConfig::default(),
lua: LuaConfig::default(),
adapters: AdaptersConfig::default(),
events: EventsConfig::default(),
}
}
}
impl Default for DaemonConfig {
fn default() -> Self {
Self {
log_level: default_log_level(),
socket_path: String::new(),
}
}
}
impl Default for LuaConfig {
fn default() -> Self {
Self {
entry_point: default_lua_entry(),
module_path: default_lua_modules(),
}
}
}
impl Default for AdaptersConfig {
fn default() -> Self {
Self {
hyprland: AdapterToggle::default(),
udev: UdevConfig::default(),
power: PowerConfig::default(),
network: AdapterToggle::default(),
}
}
}
impl Default for AdapterToggle {
fn default() -> Self {
Self {
enabled: default_true(),
}
}
}
impl Default for UdevConfig {
fn default() -> Self {
Self {
enabled: default_true(),
subsystems: default_udev_subsystems(),
}
}
}
impl Default for PowerConfig {
fn default() -> Self {
Self {
enabled: default_true(),
poll_interval_secs: default_poll_interval(),
}
}
}
impl Default for EventsConfig {
fn default() -> Self {
Self {
dedup_window_ms: default_dedup_window(),
}
}
}
impl Config {
pub fn load() -> Result<Self> {
let path = config_path();
if !path.exists() {
return Ok(Self::default());
}
let raw = fs::read_to_string(&path)?;
let cfg: Config = toml::from_str(&raw)?;
Ok(cfg)
}
pub fn socket_path(&self) -> PathBuf {
if !self.daemon.socket_path.is_empty() {
return expand_home(&self.daemon.socket_path);
}
let runtime_dir = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
Path::new(&runtime_dir).join("bread").join("breadd.sock")
}
pub fn lua_entry_point(&self) -> PathBuf {
expand_home(&self.lua.entry_point)
}
pub fn lua_module_path(&self) -> PathBuf {
expand_home(&self.lua.module_path)
}
}
fn config_path() -> PathBuf {
if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
return Path::new(&xdg).join("bread").join("breadd.toml");
}
expand_home("~/.config/bread/breadd.toml")
}
fn expand_home(input: &str) -> PathBuf {
if let Some(stripped) = input.strip_prefix("~/") {
if let Ok(home) = env::var("HOME") {
return Path::new(&home).join(stripped);
}
}
PathBuf::from(input)
}
fn default_log_level() -> String {
"info".to_string()
}
fn default_lua_entry() -> String {
"~/.config/bread/init.lua".to_string()
}
fn default_lua_modules() -> String {
"~/.config/bread/modules".to_string()
}
fn default_true() -> bool {
true
}
fn default_poll_interval() -> u64 {
30
}
fn default_dedup_window() -> u64 {
100
}
fn default_udev_subsystems() -> Vec<String> {
vec![
"usb".to_string(),
"input".to_string(),
"drm".to_string(),
"power_supply".to_string(),
]
}

6
breadd/src/core/mod.rs Normal file
View file

@ -0,0 +1,6 @@
pub mod config;
pub mod normalizer;
pub mod state_engine;
pub mod subscriptions;
pub mod supervisor;
pub mod types;

View file

@ -0,0 +1,213 @@
use std::collections::HashMap;
use std::sync::Mutex;
use bread_shared::{AdapterSource, BreadEvent, RawEvent};
use serde_json::{json, Value};
use crate::core::types::DeviceClass;
pub struct EventNormalizer {
dedup_window_ms: u64,
recent: Mutex<HashMap<String, u64>>,
}
impl EventNormalizer {
pub fn new(dedup_window_ms: u64) -> Self {
Self {
dedup_window_ms,
recent: Mutex::new(HashMap::new()),
}
}
pub fn normalize(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let mut out = match raw.source {
AdapterSource::Udev => self.normalize_udev(raw),
AdapterSource::Hyprland => self.normalize_hyprland(raw),
AdapterSource::Power => self.normalize_power(raw),
AdapterSource::Network => self.normalize_network(raw),
AdapterSource::System => vec![BreadEvent {
event: raw.kind.clone(),
timestamp: raw.timestamp,
source: raw.source,
data: raw.payload.clone(),
}],
};
out.retain(|ev| self.accept(ev));
out
}
fn normalize_udev(&self, raw: &RawEvent) -> Vec<BreadEvent> {
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('"', "");
let verb = match action {
"add" => "connected",
"remove" => "disconnected",
_ => "changed",
};
let mut events = vec![BreadEvent {
event: format!("bread.device.{}", verb),
timestamp: raw.timestamp,
source: AdapterSource::Udev,
data: json!({
"id": id,
"class": class,
"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> {
let kind = raw.payload.get("kind").and_then(Value::as_str).unwrap_or("unknown");
let mapped = match kind {
"workspace" | "workspacev2" => "bread.workspace.changed",
"monitoradded" => "bread.monitor.connected",
"monitorremoved" => "bread.monitor.disconnected",
"activewindow" | "activewindowv2" => "bread.window.focus.changed",
"openwindow" => "bread.window.opened",
"closewindow" => "bread.window.closed",
_ => "bread.hyprland.event",
};
vec![BreadEvent {
event: mapped.to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
data: raw.payload.clone(),
}]
}
fn normalize_power(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let mut events = Vec::new();
if let Some(ac) = raw.payload.get("ac_connected").and_then(Value::as_bool) {
events.push(BreadEvent {
event: if ac {
"bread.power.ac.connected".to_string()
} else {
"bread.power.ac.disconnected".to_string()
},
timestamp: raw.timestamp,
source: AdapterSource::Power,
data: raw.payload.clone(),
});
}
if let Some(level) = raw.payload.get("battery_percent").and_then(Value::as_u64) {
let battery_event = if level <= 5 {
Some("bread.power.battery.critical")
} else if level <= 10 {
Some("bread.power.battery.very_low")
} else if level <= 20 {
Some("bread.power.battery.low")
} else if level >= 100 {
Some("bread.power.battery.full")
} else {
None
};
if let Some(event) = battery_event {
events.push(BreadEvent {
event: event.to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Power,
data: raw.payload.clone(),
});
}
}
if events.is_empty() {
events.push(BreadEvent {
event: "bread.power.changed".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Power,
data: raw.payload.clone(),
});
}
events
}
fn normalize_network(&self, raw: &RawEvent) -> Vec<BreadEvent> {
let online = raw.payload.get("online").and_then(Value::as_bool).unwrap_or(false);
let name = if online {
"bread.network.connected"
} else {
"bread.network.disconnected"
};
vec![BreadEvent {
event: name.to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Network,
data: raw.payload.clone(),
}]
}
fn accept(&self, event: &BreadEvent) -> bool {
let key = format!("{}:{}", event.event, event.data);
let mut recent = self.recent.lock().expect("normalizer dedup mutex poisoned");
let now = event.timestamp;
if let Some(last) = recent.get(&key) {
if now.saturating_sub(*last) < self.dedup_window_ms {
return false;
}
}
recent.insert(key, now);
true
}
}
fn classify_device(payload: &Value) -> DeviceClass {
let name = payload
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_lowercase();
let subsystem = payload
.get("subsystem")
.and_then(Value::as_str)
.unwrap_or_default()
.to_lowercase();
if name.contains("dock") {
return DeviceClass::Dock;
}
if subsystem == "input" && name.contains("keyboard") {
return DeviceClass::Keyboard;
}
if subsystem == "input" && name.contains("mouse") {
return DeviceClass::Mouse;
}
if subsystem == "drm" {
return DeviceClass::Display;
}
if subsystem == "sound" || name.contains("audio") {
return DeviceClass::Audio;
}
if subsystem == "block" || name.contains("storage") {
return DeviceClass::Storage;
}
DeviceClass::Unknown
}

View file

@ -0,0 +1,304 @@
use std::sync::Arc;
use anyhow::Result;
use bread_shared::BreadEvent;
use serde_json::Value;
use tokio::sync::{broadcast, mpsc, watch, RwLock};
use tracing::warn;
use crate::core::subscriptions::{SubscriptionId, SubscriptionTable};
use crate::core::types::{Device, DeviceClass, InterfaceState, ModuleLoadState, RuntimeState};
use crate::lua::LuaMessage;
#[derive(Clone)]
pub struct StateHandle {
state: Arc<RwLock<RuntimeState>>,
command_tx: mpsc::UnboundedSender<StateCommand>,
}
pub enum StateCommand {
RegisterSubscription {
id: SubscriptionId,
pattern: String,
once: bool,
},
ClearSubscriptions,
SetModuleStatus {
name: String,
status: ModuleLoadState,
last_error: Option<String>,
},
SetProfile {
name: String,
},
}
impl StateHandle {
pub fn new(state: Arc<RwLock<RuntimeState>>, command_tx: mpsc::UnboundedSender<StateCommand>) -> Self {
Self { state, command_tx }
}
pub fn state_arc(&self) -> Arc<RwLock<RuntimeState>> {
self.state.clone()
}
pub async fn state_get(&self, path: &str) -> Option<Value> {
let state = self.state.read().await;
let full = serde_json::to_value(&*state).ok()?;
if path.is_empty() {
return Some(full);
}
let mut current = &full;
for part in path.split('.') {
current = current.get(part)?;
}
Some(current.clone())
}
pub async fn state_dump(&self) -> Value {
let state = self.state.read().await;
serde_json::to_value(&*state).unwrap_or_else(|_| serde_json::json!({}))
}
pub fn register_subscription(&self, id: SubscriptionId, pattern: String, once: bool) -> Result<()> {
self.command_tx
.send(StateCommand::RegisterSubscription {
id,
pattern,
once,
})
.map_err(|_| anyhow::anyhow!("state engine command channel closed"))
}
pub fn clear_subscriptions(&self) {
let _ = self.command_tx.send(StateCommand::ClearSubscriptions);
}
pub fn set_module_status(&self, name: String, status: ModuleLoadState, last_error: Option<String>) {
let _ = self.command_tx.send(StateCommand::SetModuleStatus {
name,
status,
last_error,
});
}
pub fn set_profile(&self, name: String) {
let _ = self.command_tx.send(StateCommand::SetProfile { name });
}
}
pub async fn run_state_engine(
mut event_rx: mpsc::UnboundedReceiver<BreadEvent>,
mut command_rx: mpsc::UnboundedReceiver<StateCommand>,
state: Arc<RwLock<RuntimeState>>,
lua_tx: mpsc::UnboundedSender<LuaMessage>,
event_stream_tx: broadcast::Sender<BreadEvent>,
mut shutdown_rx: watch::Receiver<bool>,
) {
let mut subscriptions = SubscriptionTable::default();
loop {
tokio::select! {
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
break;
}
}
maybe_cmd = command_rx.recv() => {
let Some(cmd) = maybe_cmd else {
break;
};
handle_command(cmd, &state, &mut subscriptions).await;
}
maybe_event = event_rx.recv() => {
let Some(event) = maybe_event else {
break;
};
apply_event_to_state(&state, &event).await;
let _ = event_stream_tx.send(event.clone());
let matches = subscriptions.match_event(&event.event);
for sub in &matches {
let _ = lua_tx.send(LuaMessage::Event {
subscription_id: sub.id,
event: event.clone(),
});
}
for sub in matches.into_iter().filter(|s| s.once) {
subscriptions.remove(sub.id);
let _ = lua_tx.send(LuaMessage::SubscriptionCancelled { id: sub.id });
}
}
}
}
warn!("state engine loop exited");
}
async fn handle_command(
cmd: StateCommand,
state: &Arc<RwLock<RuntimeState>>,
subscriptions: &mut SubscriptionTable,
) {
match cmd {
StateCommand::RegisterSubscription { id, pattern, once } => {
subscriptions.add_with_id(id, pattern, once);
}
StateCommand::ClearSubscriptions => {
subscriptions.clear();
}
StateCommand::SetModuleStatus {
name,
status,
last_error,
} => {
let mut guard = state.write().await;
if let Some(existing) = guard.modules.iter_mut().find(|m| m.name == name) {
existing.status = status;
existing.last_error = last_error;
} else {
guard.modules.push(crate::core::types::ModuleStatus {
name,
status,
last_error,
});
}
}
StateCommand::SetProfile { name } => {
let mut guard = state.write().await;
if guard.profile.active != name {
let previous = guard.profile.active.clone();
guard.profile.history.push(previous);
guard.profile.active = name;
}
}
}
}
async fn apply_event_to_state(state: &Arc<RwLock<RuntimeState>>, event: &BreadEvent) {
let mut guard = state.write().await;
match event.event.as_str() {
"bread.monitor.connected" => {
if let Some(name) = event.data.get("name").and_then(Value::as_str) {
if let Some(m) = guard.monitors.iter_mut().find(|m| m.name == name) {
m.connected = true;
} else {
guard.monitors.push(crate::core::types::Monitor {
name: name.to_string(),
connected: true,
resolution: event.data.get("resolution").and_then(Value::as_str).map(ToString::to_string),
position: event.data.get("position").and_then(Value::as_str).map(ToString::to_string),
});
}
}
}
"bread.monitor.disconnected" => {
if let Some(name) = event.data.get("name").and_then(Value::as_str) {
if let Some(m) = guard.monitors.iter_mut().find(|m| m.name == name) {
m.connected = false;
}
}
}
"bread.workspace.changed" => {
let ws = event
.data
.get("workspace")
.or_else(|| event.data.get("id"))
.and_then(Value::as_str)
.map(ToString::to_string);
guard.active_workspace = ws;
}
"bread.window.focus.changed" => {
guard.active_window = event
.data
.get("window")
.or_else(|| event.data.get("class"))
.and_then(Value::as_str)
.map(ToString::to_string);
}
"bread.device.connected" => {
apply_device_change(&mut guard, &event.data, true);
}
"bread.device.disconnected" => {
apply_device_change(&mut guard, &event.data, false);
}
"bread.network.connected" | "bread.network.disconnected" => {
if let Some(online) = event.data.get("online").and_then(Value::as_bool) {
guard.network.online = online;
}
if let Some(ifaces) = event.data.get("interfaces").and_then(Value::as_object) {
guard.network.interfaces.clear();
for (name, meta) in ifaces {
let up = meta.get("up").and_then(Value::as_bool).unwrap_or(false);
guard.network.interfaces.insert(name.clone(), InterfaceState { up });
}
}
}
"bread.power.changed"
| "bread.power.ac.connected"
| "bread.power.ac.disconnected"
| "bread.power.battery.low"
| "bread.power.battery.very_low"
| "bread.power.battery.critical"
| "bread.power.battery.full" => {
if let Some(ac) = event.data.get("ac_connected").and_then(Value::as_bool) {
guard.power.ac_connected = ac;
}
if let Some(battery) = event.data.get("battery_percent").and_then(Value::as_u64) {
guard.power.battery_percent = Some(battery.min(100) as u8);
guard.power.battery_low = battery <= 20;
}
}
"bread.profile.activated" => {
if let Some(name) = event.data.get("name").and_then(Value::as_str) {
if guard.profile.active != name {
let previous = guard.profile.active.clone();
guard.profile.history.push(previous);
guard.profile.active = name.to_string();
}
}
}
_ => {}
}
}
fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool) {
let id = data
.get("id")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
if connected {
if state.devices.connected.iter().any(|d| d.id == id) {
return;
}
let class = data
.get("class")
.and_then(|v| serde_json::from_value::<DeviceClass>(v.clone()).ok())
.unwrap_or(DeviceClass::Unknown);
state.devices.connected.push(Device {
id,
name: data
.get("name")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
class,
subsystem: data
.get("subsystem")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
});
} else {
state.devices.connected.retain(|d| d.id != id);
}
}

View file

@ -0,0 +1,63 @@
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub struct SubscriptionId(pub u64);
#[derive(Debug, Clone)]
pub struct Subscription {
pub id: SubscriptionId,
pub pattern: String,
pub once: bool,
}
#[derive(Default, Debug)]
pub struct SubscriptionTable {
entries: Vec<Subscription>,
by_id: HashMap<SubscriptionId, usize>,
next_id: u64,
}
impl SubscriptionTable {
pub fn add_with_id(&mut self, id: SubscriptionId, pattern: String, once: bool) -> SubscriptionId {
self.next_id = self.next_id.max(id.0.saturating_add(1));
let sub = Subscription { id, pattern, once };
self.entries.push(sub);
self.by_id.insert(id, self.entries.len() - 1);
id
}
pub fn remove(&mut self, id: SubscriptionId) -> bool {
let Some(idx) = self.by_id.remove(&id) else {
return false;
};
self.entries.swap_remove(idx);
if let Some(swapped) = self.entries.get(idx) {
self.by_id.insert(swapped.id, idx);
}
true
}
pub fn clear(&mut self) {
self.entries.clear();
self.by_id.clear();
}
pub fn match_event(&self, event_name: &str) -> Vec<Subscription> {
self.entries
.iter()
.filter(|sub| matches_pattern(&sub.pattern, event_name))
.cloned()
.collect()
}
}
fn matches_pattern(pattern: &str, event_name: &str) -> bool {
if pattern.ends_with(".*") {
let prefix = &pattern[..pattern.len() - 1];
return event_name.starts_with(prefix);
}
pattern == event_name
}

View file

@ -0,0 +1,65 @@
use std::future::Future;
use tokio::sync::watch;
use tokio::time::{sleep, Duration};
use tracing::{error, info, warn};
pub fn spawn_supervised<F, Fut>(
name: &'static str,
mut shutdown_rx: watch::Receiver<bool>,
mut task_factory: F,
)
where
F: FnMut() -> Fut + Send + 'static,
Fut: Future<Output = anyhow::Result<()>> + Send + 'static,
{
tokio::spawn(async move {
let mut attempt: u32 = 0;
loop {
if *shutdown_rx.borrow() {
info!(adapter = name, "shutdown requested");
break;
}
let result = tokio::select! {
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
info!(adapter = name, "shutdown requested");
break;
}
continue;
}
result = task_factory() => result,
};
match result {
Ok(()) => {
info!(adapter = name, "adapter task exited cleanly");
attempt = 0;
}
Err(err) => {
error!(adapter = name, error = %err, "adapter task failed");
attempt = attempt.saturating_add(1);
}
}
if *shutdown_rx.borrow() {
info!(adapter = name, "shutdown requested");
break;
}
let wait_ms = 500u64.saturating_mul(2u64.saturating_pow(attempt.min(6)));
warn!(adapter = name, delay_ms = wait_ms, "restarting adapter after failure");
tokio::select! {
_ = sleep(Duration::from_millis(wait_ms)) => {},
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
info!(adapter = name, "shutdown requested");
break;
}
}
}
}
});
}

132
breadd/src/core/types.rs Normal file
View file

@ -0,0 +1,132 @@
use std::collections::{BTreeMap, HashMap};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeState {
pub monitors: Vec<Monitor>,
pub workspaces: Vec<Workspace>,
pub active_workspace: Option<String>,
pub active_window: Option<String>,
pub devices: DeviceTopology,
pub network: NetworkState,
pub power: PowerState,
pub profile: ProfileState,
pub modules: Vec<ModuleStatus>,
}
impl Default for RuntimeState {
fn default() -> Self {
Self {
monitors: Vec::new(),
workspaces: Vec::new(),
active_workspace: None,
active_window: None,
devices: DeviceTopology::default(),
network: NetworkState::default(),
power: PowerState::default(),
profile: ProfileState::default(),
modules: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Monitor {
pub name: String,
pub connected: bool,
pub resolution: Option<String>,
pub position: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workspace {
pub id: String,
pub monitor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DeviceTopology {
pub connected: Vec<Device>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Device {
pub id: String,
pub name: String,
pub class: DeviceClass,
pub subsystem: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum DeviceClass {
Dock,
Keyboard,
Mouse,
Tablet,
Display,
Storage,
Audio,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct NetworkState {
pub interfaces: HashMap<String, InterfaceState>,
pub online: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InterfaceState {
pub up: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PowerState {
pub ac_connected: bool,
pub battery_percent: Option<u8>,
pub battery_low: bool,
}
impl Default for PowerState {
fn default() -> Self {
Self {
ac_connected: false,
battery_percent: None,
battery_low: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileState {
pub active: String,
pub history: Vec<String>,
pub profiles: BTreeMap<String, String>,
}
impl Default for ProfileState {
fn default() -> Self {
Self {
active: "default".to_string(),
history: Vec::new(),
profiles: BTreeMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModuleStatus {
pub name: String,
pub status: ModuleLoadState,
pub last_error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ModuleLoadState {
Loaded,
LoadError,
NotFound,
}