Add volume policy
This commit is contained in:
parent
234a64de3d
commit
fb7503eeb4
17 changed files with 1272 additions and 107 deletions
|
|
@ -13,6 +13,14 @@ config_version = 1
|
||||||
# Set to 0 for unlimited (no time limit)
|
# Set to 0 for unlimited (no time limit)
|
||||||
default_max_run_seconds = 3600
|
default_max_run_seconds = 3600
|
||||||
|
|
||||||
|
# Global volume restrictions (optional)
|
||||||
|
# These apply when no entry-specific restrictions are defined
|
||||||
|
[daemon.volume]
|
||||||
|
max_volume = 80 # Maximum volume percentage (0-100)
|
||||||
|
# min_volume = 20 # Minimum volume percentage (0-100)
|
||||||
|
allow_mute = true # Whether mute toggle is allowed
|
||||||
|
allow_change = true # Whether volume changes are allowed at all
|
||||||
|
|
||||||
# Default warning thresholds
|
# Default warning thresholds
|
||||||
[[daemon.default_warnings]]
|
[[daemon.default_warnings]]
|
||||||
seconds_before = 300
|
seconds_before = 300
|
||||||
|
|
@ -96,6 +104,10 @@ seconds_before = 30
|
||||||
severity = "critical"
|
severity = "critical"
|
||||||
message = "30 seconds! Save NOW!"
|
message = "30 seconds! Save NOW!"
|
||||||
|
|
||||||
|
# Entry-specific volume restrictions (overrides global)
|
||||||
|
[entries.volume]
|
||||||
|
max_volume = 60 # Limit volume during gaming sessions
|
||||||
|
|
||||||
# Example: Educational game
|
# Example: Educational game
|
||||||
[[entries]]
|
[[entries]]
|
||||||
id = "tuxmath"
|
id = "tuxmath"
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,26 @@ pub enum Command {
|
||||||
/// Get health status
|
/// Get health status
|
||||||
GetHealth,
|
GetHealth,
|
||||||
|
|
||||||
|
// Volume control commands
|
||||||
|
|
||||||
|
/// Get current volume status
|
||||||
|
GetVolume,
|
||||||
|
|
||||||
|
/// Set volume to a specific percentage
|
||||||
|
SetVolume { percent: u8 },
|
||||||
|
|
||||||
|
/// Increase volume by a step
|
||||||
|
VolumeUp { step: u8 },
|
||||||
|
|
||||||
|
/// Decrease volume by a step
|
||||||
|
VolumeDown { step: u8 },
|
||||||
|
|
||||||
|
/// Toggle mute state
|
||||||
|
ToggleMute,
|
||||||
|
|
||||||
|
/// Set mute state explicitly
|
||||||
|
SetMute { muted: bool },
|
||||||
|
|
||||||
// Admin commands
|
// Admin commands
|
||||||
|
|
||||||
/// Extend the current session (admin only)
|
/// Extend the current session (admin only)
|
||||||
|
|
@ -161,6 +181,11 @@ pub enum ResponsePayload {
|
||||||
/// New deadline. None if session is unlimited (can't be extended).
|
/// New deadline. None if session is unlimited (can't be extended).
|
||||||
new_deadline: Option<DateTime<Local>>,
|
new_deadline: Option<DateTime<Local>>,
|
||||||
},
|
},
|
||||||
|
Volume(crate::VolumeInfo),
|
||||||
|
VolumeSet,
|
||||||
|
VolumeDenied {
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
Pong,
|
Pong,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,12 @@ pub enum EventPayload {
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Volume status changed
|
||||||
|
VolumeChanged {
|
||||||
|
percent: u8,
|
||||||
|
muted: bool,
|
||||||
|
},
|
||||||
|
|
||||||
/// Daemon is shutting down
|
/// Daemon is shutting down
|
||||||
Shutdown,
|
Shutdown,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,70 @@ pub struct HealthStatus {
|
||||||
pub store_ok: bool,
|
pub store_ok: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Volume status information
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct VolumeInfo {
|
||||||
|
/// Volume percentage (0-100)
|
||||||
|
pub percent: u8,
|
||||||
|
/// Whether audio is muted
|
||||||
|
pub muted: bool,
|
||||||
|
/// Whether volume control is available
|
||||||
|
pub available: bool,
|
||||||
|
/// The detected sound backend (e.g., "pipewire", "pulseaudio", "alsa")
|
||||||
|
pub backend: Option<String>,
|
||||||
|
/// Current restrictions on volume
|
||||||
|
pub restrictions: VolumeRestrictions,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Volume restrictions that are currently in effect
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct VolumeRestrictions {
|
||||||
|
/// Maximum volume percentage allowed
|
||||||
|
pub max_volume: Option<u8>,
|
||||||
|
/// Minimum volume percentage allowed
|
||||||
|
pub min_volume: Option<u8>,
|
||||||
|
/// Whether mute toggle is allowed
|
||||||
|
pub allow_mute: bool,
|
||||||
|
/// Whether volume changes are allowed at all
|
||||||
|
pub allow_change: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VolumeRestrictions {
|
||||||
|
/// Create unrestricted volume settings
|
||||||
|
pub fn unrestricted() -> Self {
|
||||||
|
Self {
|
||||||
|
max_volume: None,
|
||||||
|
min_volume: None,
|
||||||
|
allow_mute: true,
|
||||||
|
allow_change: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamp a volume value to the allowed range
|
||||||
|
pub fn clamp_volume(&self, percent: u8) -> u8 {
|
||||||
|
let min = self.min_volume.unwrap_or(0);
|
||||||
|
let max = self.max_volume.unwrap_or(100);
|
||||||
|
percent.clamp(min, max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VolumeInfo {
|
||||||
|
/// Get an icon name for the current volume status
|
||||||
|
pub fn icon_name(&self) -> &'static str {
|
||||||
|
if self.muted {
|
||||||
|
"audio-volume-muted-symbolic"
|
||||||
|
} else if self.percent == 0 {
|
||||||
|
"audio-volume-muted-symbolic"
|
||||||
|
} else if self.percent < 33 {
|
||||||
|
"audio-volume-low-symbolic"
|
||||||
|
} else if self.percent < 66 {
|
||||||
|
"audio-volume-medium-symbolic"
|
||||||
|
} else {
|
||||||
|
"audio-volume-high-symbolic"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! Validated policy structures
|
//! Validated policy structures
|
||||||
|
|
||||||
use crate::schema::{RawConfig, RawDays, RawEntry, RawEntryKind, RawWarningThreshold};
|
use crate::schema::{RawConfig, RawDays, RawEntry, RawEntryKind, RawVolumeConfig, RawWarningThreshold};
|
||||||
use crate::validation::{parse_days, parse_time};
|
use crate::validation::{parse_days, parse_time};
|
||||||
use shepherd_api::{EntryKind, WarningSeverity, WarningThreshold};
|
use shepherd_api::{EntryKind, WarningSeverity, WarningThreshold};
|
||||||
use shepherd_util::{DaysOfWeek, EntryId, TimeWindow, WallClock};
|
use shepherd_util::{DaysOfWeek, EntryId, TimeWindow, WallClock};
|
||||||
|
|
@ -22,6 +22,9 @@ pub struct Policy {
|
||||||
|
|
||||||
/// Default max run duration. None means unlimited.
|
/// Default max run duration. None means unlimited.
|
||||||
pub default_max_run: Option<Duration>,
|
pub default_max_run: Option<Duration>,
|
||||||
|
|
||||||
|
/// Global volume restrictions
|
||||||
|
pub volume: VolumePolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Policy {
|
impl Policy {
|
||||||
|
|
@ -41,10 +44,17 @@ impl Policy {
|
||||||
.map(seconds_to_duration_or_unlimited)
|
.map(seconds_to_duration_or_unlimited)
|
||||||
.unwrap_or(Some(Duration::from_secs(3600))); // 1 hour default
|
.unwrap_or(Some(Duration::from_secs(3600))); // 1 hour default
|
||||||
|
|
||||||
|
let global_volume = raw
|
||||||
|
.daemon
|
||||||
|
.volume
|
||||||
|
.as_ref()
|
||||||
|
.map(convert_volume_config)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
let entries = raw
|
let entries = raw
|
||||||
.entries
|
.entries
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|e| Entry::from_raw(e, &default_warnings, default_max_run))
|
.map(|e| Entry::from_raw(e, &default_warnings, default_max_run, &global_volume))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -52,6 +62,7 @@ impl Policy {
|
||||||
entries,
|
entries,
|
||||||
default_warnings,
|
default_warnings,
|
||||||
default_max_run,
|
default_max_run,
|
||||||
|
volume: global_volume,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,6 +116,7 @@ pub struct Entry {
|
||||||
pub availability: AvailabilityPolicy,
|
pub availability: AvailabilityPolicy,
|
||||||
pub limits: LimitsPolicy,
|
pub limits: LimitsPolicy,
|
||||||
pub warnings: Vec<WarningThreshold>,
|
pub warnings: Vec<WarningThreshold>,
|
||||||
|
pub volume: Option<VolumePolicy>,
|
||||||
pub disabled: bool,
|
pub disabled: bool,
|
||||||
pub disabled_reason: Option<String>,
|
pub disabled_reason: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
@ -114,6 +126,7 @@ impl Entry {
|
||||||
raw: RawEntry,
|
raw: RawEntry,
|
||||||
default_warnings: &[WarningThreshold],
|
default_warnings: &[WarningThreshold],
|
||||||
default_max_run: Option<Duration>,
|
default_max_run: Option<Duration>,
|
||||||
|
_global_volume: &VolumePolicy,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let kind = convert_entry_kind(raw.kind);
|
let kind = convert_entry_kind(raw.kind);
|
||||||
let availability = raw
|
let availability = raw
|
||||||
|
|
@ -132,6 +145,7 @@ impl Entry {
|
||||||
.warnings
|
.warnings
|
||||||
.map(|w| w.into_iter().map(convert_warning).collect())
|
.map(|w| w.into_iter().map(convert_warning).collect())
|
||||||
.unwrap_or_else(|| default_warnings.to_vec());
|
.unwrap_or_else(|| default_warnings.to_vec());
|
||||||
|
let volume = raw.volume.as_ref().map(convert_volume_config);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
id: EntryId::new(raw.id),
|
id: EntryId::new(raw.id),
|
||||||
|
|
@ -141,6 +155,7 @@ impl Entry {
|
||||||
availability,
|
availability,
|
||||||
limits,
|
limits,
|
||||||
warnings,
|
warnings,
|
||||||
|
volume,
|
||||||
disabled: raw.disabled,
|
disabled: raw.disabled,
|
||||||
disabled_reason: raw.disabled_reason,
|
disabled_reason: raw.disabled_reason,
|
||||||
}
|
}
|
||||||
|
|
@ -190,6 +205,38 @@ pub struct LimitsPolicy {
|
||||||
pub cooldown: Option<Duration>,
|
pub cooldown: Option<Duration>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Volume control policy
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct VolumePolicy {
|
||||||
|
/// Maximum volume percentage allowed (enforced by daemon)
|
||||||
|
pub max_volume: Option<u8>,
|
||||||
|
/// Minimum volume percentage allowed (enforced by daemon)
|
||||||
|
pub min_volume: Option<u8>,
|
||||||
|
/// Whether mute toggle is allowed
|
||||||
|
pub allow_mute: bool,
|
||||||
|
/// Whether volume changes are allowed at all
|
||||||
|
pub allow_change: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VolumePolicy {
|
||||||
|
/// Create unrestricted volume settings
|
||||||
|
pub fn unrestricted() -> Self {
|
||||||
|
Self {
|
||||||
|
max_volume: None,
|
||||||
|
min_volume: None,
|
||||||
|
allow_mute: true,
|
||||||
|
allow_change: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamp a volume value to the allowed range
|
||||||
|
pub fn clamp_volume(&self, percent: u8) -> u8 {
|
||||||
|
let min = self.min_volume.unwrap_or(0);
|
||||||
|
let max = self.max_volume.unwrap_or(100);
|
||||||
|
percent.clamp(min, max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Conversion helpers
|
// Conversion helpers
|
||||||
|
|
||||||
fn convert_entry_kind(raw: RawEntryKind) -> EntryKind {
|
fn convert_entry_kind(raw: RawEntryKind) -> EntryKind {
|
||||||
|
|
@ -213,6 +260,15 @@ fn convert_availability(raw: crate::schema::RawAvailability) -> AvailabilityPoli
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn convert_volume_config(raw: &RawVolumeConfig) -> VolumePolicy {
|
||||||
|
VolumePolicy {
|
||||||
|
max_volume: raw.max_volume,
|
||||||
|
min_volume: raw.min_volume,
|
||||||
|
allow_mute: raw.allow_mute,
|
||||||
|
allow_change: raw.allow_change,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn convert_time_window(raw: crate::schema::RawTimeWindow) -> TimeWindow {
|
fn convert_time_window(raw: crate::schema::RawTimeWindow) -> TimeWindow {
|
||||||
let days_mask = parse_days(&raw.days).unwrap_or(0x7F);
|
let days_mask = parse_days(&raw.days).unwrap_or(0x7F);
|
||||||
let (start_h, start_m) = parse_time(&raw.start).unwrap_or((0, 0));
|
let (start_h, start_m) = parse_time(&raw.start).unwrap_or((0, 0));
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,10 @@ pub struct RawDaemonConfig {
|
||||||
|
|
||||||
/// Default max run duration
|
/// Default max run duration
|
||||||
pub default_max_run_seconds: Option<u64>,
|
pub default_max_run_seconds: Option<u64>,
|
||||||
|
|
||||||
|
/// Global volume restrictions
|
||||||
|
#[serde(default)]
|
||||||
|
pub volume: Option<RawVolumeConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Raw entry definition
|
/// Raw entry definition
|
||||||
|
|
@ -65,6 +69,10 @@ pub struct RawEntry {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub warnings: Option<Vec<RawWarningThreshold>>,
|
pub warnings: Option<Vec<RawWarningThreshold>>,
|
||||||
|
|
||||||
|
/// Volume restrictions for this entry (overrides global)
|
||||||
|
#[serde(default)]
|
||||||
|
pub volume: Option<RawVolumeConfig>,
|
||||||
|
|
||||||
/// Explicitly disabled
|
/// Explicitly disabled
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub disabled: bool,
|
pub disabled: bool,
|
||||||
|
|
@ -181,6 +189,28 @@ fn default_severity() -> String {
|
||||||
"warn".to_string()
|
"warn".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Volume control configuration
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
|
pub struct RawVolumeConfig {
|
||||||
|
/// Maximum volume percentage allowed (0-100)
|
||||||
|
pub max_volume: Option<u8>,
|
||||||
|
|
||||||
|
/// Minimum volume percentage allowed (0-100)
|
||||||
|
pub min_volume: Option<u8>,
|
||||||
|
|
||||||
|
/// Whether mute toggle is allowed (default: true)
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub allow_mute: bool,
|
||||||
|
|
||||||
|
/// Whether volume changes are allowed at all (default: true)
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub allow_change: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -260,6 +260,7 @@ mod tests {
|
||||||
availability: None,
|
availability: None,
|
||||||
limits: None,
|
limits: None,
|
||||||
warnings: None,
|
warnings: None,
|
||||||
|
volume: None,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
disabled_reason: None,
|
disabled_reason: None,
|
||||||
},
|
},
|
||||||
|
|
@ -276,6 +277,7 @@ mod tests {
|
||||||
availability: None,
|
availability: None,
|
||||||
limits: None,
|
limits: None,
|
||||||
warnings: None,
|
warnings: None,
|
||||||
|
volume: None,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
disabled_reason: None,
|
disabled_reason: None,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -574,11 +574,13 @@ mod tests {
|
||||||
cooldown: None,
|
cooldown: None,
|
||||||
},
|
},
|
||||||
warnings: vec![],
|
warnings: vec![],
|
||||||
|
volume: None,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
disabled_reason: None,
|
disabled_reason: None,
|
||||||
}],
|
}],
|
||||||
default_warnings: vec![],
|
default_warnings: vec![],
|
||||||
default_max_run: Some(Duration::from_secs(3600)),
|
default_max_run: Some(Duration::from_secs(3600)),
|
||||||
|
volume: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -655,12 +657,14 @@ mod tests {
|
||||||
severity: WarningSeverity::Warn,
|
severity: WarningSeverity::Warn,
|
||||||
message_template: Some("1 minute left".into()),
|
message_template: Some("1 minute left".into()),
|
||||||
}],
|
}],
|
||||||
|
volume: None,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
disabled_reason: None,
|
disabled_reason: None,
|
||||||
}],
|
}],
|
||||||
daemon: Default::default(),
|
daemon: Default::default(),
|
||||||
default_warnings: vec![],
|
default_warnings: vec![],
|
||||||
default_max_run: Some(Duration::from_secs(3600)),
|
default_max_run: Some(Duration::from_secs(3600)),
|
||||||
|
volume: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let store = Arc::new(SqliteStore::in_memory().unwrap());
|
let store = Arc::new(SqliteStore::in_memory().unwrap());
|
||||||
|
|
@ -714,12 +718,14 @@ mod tests {
|
||||||
cooldown: None,
|
cooldown: None,
|
||||||
},
|
},
|
||||||
warnings: vec![],
|
warnings: vec![],
|
||||||
|
volume: None,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
disabled_reason: None,
|
disabled_reason: None,
|
||||||
}],
|
}],
|
||||||
daemon: Default::default(),
|
daemon: Default::default(),
|
||||||
default_warnings: vec![],
|
default_warnings: vec![],
|
||||||
default_max_run: Some(Duration::from_secs(3600)),
|
default_max_run: Some(Duration::from_secs(3600)),
|
||||||
|
volume: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let store = Arc::new(SqliteStore::in_memory().unwrap());
|
let store = Arc::new(SqliteStore::in_memory().unwrap());
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@ mod capabilities;
|
||||||
mod handle;
|
mod handle;
|
||||||
mod mock;
|
mod mock;
|
||||||
mod traits;
|
mod traits;
|
||||||
|
mod volume;
|
||||||
|
|
||||||
pub use capabilities::*;
|
pub use capabilities::*;
|
||||||
pub use handle::*;
|
pub use handle::*;
|
||||||
pub use mock::*;
|
pub use mock::*;
|
||||||
pub use traits::*;
|
pub use traits::*;
|
||||||
|
pub use volume::*;
|
||||||
|
|
|
||||||
171
crates/shepherd-host-api/src/volume.rs
Normal file
171
crates/shepherd-host-api/src/volume.rs
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
//! Volume control trait interfaces
|
||||||
|
//!
|
||||||
|
//! Defines the capability-based interface for volume control between
|
||||||
|
//! the daemon core and platform-specific implementations.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Errors from volume control operations
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum VolumeError {
|
||||||
|
#[error("Volume control not available: {0}")]
|
||||||
|
NotAvailable(String),
|
||||||
|
|
||||||
|
#[error("Backend error: {0}")]
|
||||||
|
Backend(String),
|
||||||
|
|
||||||
|
#[error("Volume out of range: {0}")]
|
||||||
|
OutOfRange(u8),
|
||||||
|
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type VolumeResult<T> = Result<T, VolumeError>;
|
||||||
|
|
||||||
|
/// Volume status
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct VolumeStatus {
|
||||||
|
/// Volume percentage (0-100, can exceed if system allows)
|
||||||
|
pub percent: u8,
|
||||||
|
/// Whether audio is muted
|
||||||
|
pub muted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VolumeStatus {
|
||||||
|
/// Get an icon name for the current volume status
|
||||||
|
pub fn icon_name(&self) -> &'static str {
|
||||||
|
if self.muted {
|
||||||
|
"audio-volume-muted-symbolic"
|
||||||
|
} else if self.percent == 0 {
|
||||||
|
"audio-volume-muted-symbolic"
|
||||||
|
} else if self.percent < 33 {
|
||||||
|
"audio-volume-low-symbolic"
|
||||||
|
} else if self.percent < 66 {
|
||||||
|
"audio-volume-medium-symbolic"
|
||||||
|
} else {
|
||||||
|
"audio-volume-high-symbolic"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Volume capabilities
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct VolumeCapabilities {
|
||||||
|
/// Whether volume control is available
|
||||||
|
pub available: bool,
|
||||||
|
/// The detected sound backend (e.g., "pipewire", "pulseaudio", "alsa")
|
||||||
|
pub backend: Option<String>,
|
||||||
|
/// Whether mute control is available
|
||||||
|
pub can_mute: bool,
|
||||||
|
/// Maximum volume percentage allowed (for systems that allow >100%)
|
||||||
|
pub max_volume: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Volume restrictions that can be enforced by policy
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct VolumeRestrictions {
|
||||||
|
/// Maximum volume percentage allowed (enforced by daemon)
|
||||||
|
pub max_volume: Option<u8>,
|
||||||
|
/// Minimum volume percentage allowed (enforced by daemon)
|
||||||
|
pub min_volume: Option<u8>,
|
||||||
|
/// Whether mute toggle is allowed
|
||||||
|
pub allow_mute: bool,
|
||||||
|
/// Whether volume changes are allowed at all
|
||||||
|
pub allow_change: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VolumeRestrictions {
|
||||||
|
/// Create unrestricted volume settings
|
||||||
|
pub fn unrestricted() -> Self {
|
||||||
|
Self {
|
||||||
|
max_volume: None,
|
||||||
|
min_volume: None,
|
||||||
|
allow_mute: true,
|
||||||
|
allow_change: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamp a volume value to the allowed range
|
||||||
|
pub fn clamp_volume(&self, percent: u8) -> u8 {
|
||||||
|
let min = self.min_volume.unwrap_or(0);
|
||||||
|
let max = self.max_volume.unwrap_or(100);
|
||||||
|
percent.clamp(min, max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Volume controller trait - implemented by platform-specific adapters
|
||||||
|
#[async_trait]
|
||||||
|
pub trait VolumeController: Send + Sync {
|
||||||
|
/// Get the capabilities of this volume controller
|
||||||
|
fn capabilities(&self) -> &VolumeCapabilities;
|
||||||
|
|
||||||
|
/// Get current volume status
|
||||||
|
async fn get_status(&self) -> VolumeResult<VolumeStatus>;
|
||||||
|
|
||||||
|
/// Set volume to a specific percentage
|
||||||
|
async fn set_volume(&self, percent: u8) -> VolumeResult<()>;
|
||||||
|
|
||||||
|
/// Increase volume by a step
|
||||||
|
async fn volume_up(&self, step: u8) -> VolumeResult<()>;
|
||||||
|
|
||||||
|
/// Decrease volume by a step
|
||||||
|
async fn volume_down(&self, step: u8) -> VolumeResult<()>;
|
||||||
|
|
||||||
|
/// Toggle mute state
|
||||||
|
async fn toggle_mute(&self) -> VolumeResult<()>;
|
||||||
|
|
||||||
|
/// Set mute state explicitly
|
||||||
|
async fn set_mute(&self, muted: bool) -> VolumeResult<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_volume_icon_names() {
|
||||||
|
let status = VolumeStatus {
|
||||||
|
percent: 0,
|
||||||
|
muted: false,
|
||||||
|
};
|
||||||
|
assert_eq!(status.icon_name(), "audio-volume-muted-symbolic");
|
||||||
|
|
||||||
|
let status = VolumeStatus {
|
||||||
|
percent: 50,
|
||||||
|
muted: false,
|
||||||
|
};
|
||||||
|
assert_eq!(status.icon_name(), "audio-volume-medium-symbolic");
|
||||||
|
|
||||||
|
let status = VolumeStatus {
|
||||||
|
percent: 100,
|
||||||
|
muted: true,
|
||||||
|
};
|
||||||
|
assert_eq!(status.icon_name(), "audio-volume-muted-symbolic");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_restrictions_clamp() {
|
||||||
|
let restrictions = VolumeRestrictions {
|
||||||
|
max_volume: Some(80),
|
||||||
|
min_volume: Some(20),
|
||||||
|
allow_mute: true,
|
||||||
|
allow_change: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(restrictions.clamp_volume(50), 50);
|
||||||
|
assert_eq!(restrictions.clamp_volume(10), 20);
|
||||||
|
assert_eq!(restrictions.clamp_volume(90), 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unrestricted() {
|
||||||
|
let restrictions = VolumeRestrictions::unrestricted();
|
||||||
|
assert_eq!(restrictions.clamp_volume(0), 0);
|
||||||
|
assert_eq!(restrictions.clamp_volume(100), 100);
|
||||||
|
assert!(restrictions.allow_mute);
|
||||||
|
assert!(restrictions.allow_change);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,9 +5,12 @@
|
||||||
//! - Graceful (SIGTERM) and forceful (SIGKILL) termination
|
//! - Graceful (SIGTERM) and forceful (SIGKILL) termination
|
||||||
//! - Exit observation
|
//! - Exit observation
|
||||||
//! - stdout/stderr capture
|
//! - stdout/stderr capture
|
||||||
|
//! - Volume control with auto-detection of sound systems
|
||||||
|
|
||||||
mod adapter;
|
mod adapter;
|
||||||
mod process;
|
mod process;
|
||||||
|
mod volume;
|
||||||
|
|
||||||
pub use adapter::*;
|
pub use adapter::*;
|
||||||
pub use process::*;
|
pub use process::*;
|
||||||
|
pub use volume::*;
|
||||||
|
|
|
||||||
372
crates/shepherd-host-linux/src/volume.rs
Normal file
372
crates/shepherd-host-linux/src/volume.rs
Normal file
|
|
@ -0,0 +1,372 @@
|
||||||
|
//! Linux volume control implementation
|
||||||
|
//!
|
||||||
|
//! Provides volume control with auto-detection of sound systems:
|
||||||
|
//! - PipeWire (via `wpctl`)
|
||||||
|
//! - PulseAudio (via `pactl`)
|
||||||
|
//! - ALSA (via `amixer`)
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use shepherd_host_api::{
|
||||||
|
VolumeCapabilities, VolumeController, VolumeError, VolumeResult, VolumeStatus,
|
||||||
|
};
|
||||||
|
use std::process::Command;
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
/// Detected sound backend
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SoundBackend {
|
||||||
|
/// PipeWire with WirePlumber
|
||||||
|
PipeWire,
|
||||||
|
/// PulseAudio
|
||||||
|
PulseAudio,
|
||||||
|
/// ALSA (direct)
|
||||||
|
Alsa,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SoundBackend {
|
||||||
|
/// Detect the best available sound backend
|
||||||
|
pub fn detect() -> Option<Self> {
|
||||||
|
// Try PipeWire first (modern systems)
|
||||||
|
if Self::is_pipewire_available() {
|
||||||
|
info!("Detected PipeWire sound backend");
|
||||||
|
return Some(Self::PipeWire);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try PulseAudio
|
||||||
|
if Self::is_pulseaudio_available() {
|
||||||
|
info!("Detected PulseAudio sound backend");
|
||||||
|
return Some(Self::PulseAudio);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try ALSA as fallback
|
||||||
|
if Self::is_alsa_available() {
|
||||||
|
info!("Detected ALSA sound backend");
|
||||||
|
return Some(Self::Alsa);
|
||||||
|
}
|
||||||
|
|
||||||
|
warn!("No sound backend detected");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_pipewire_available() -> bool {
|
||||||
|
// Check if wpctl is available and can communicate with PipeWire
|
||||||
|
Command::new("wpctl")
|
||||||
|
.args(["status"])
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_pulseaudio_available() -> bool {
|
||||||
|
// Check if pactl is available and server is running
|
||||||
|
Command::new("pactl")
|
||||||
|
.args(["info"])
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_alsa_available() -> bool {
|
||||||
|
// Check if amixer is available
|
||||||
|
Command::new("amixer")
|
||||||
|
.args(["sget", "Master"])
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::PipeWire => "pipewire",
|
||||||
|
Self::PulseAudio => "pulseaudio",
|
||||||
|
Self::Alsa => "alsa",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linux volume controller with auto-detection
|
||||||
|
pub struct LinuxVolumeController {
|
||||||
|
capabilities: VolumeCapabilities,
|
||||||
|
backend: Option<SoundBackend>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinuxVolumeController {
|
||||||
|
/// Create a new volume controller with auto-detection
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let backend = SoundBackend::detect();
|
||||||
|
|
||||||
|
let capabilities = VolumeCapabilities {
|
||||||
|
available: backend.is_some(),
|
||||||
|
backend: backend.map(|b| b.name().to_string()),
|
||||||
|
can_mute: backend.is_some(),
|
||||||
|
max_volume: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
capabilities,
|
||||||
|
backend,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get volume status via PipeWire
|
||||||
|
fn get_status_pipewire() -> VolumeResult<VolumeStatus> {
|
||||||
|
// Get volume: wpctl get-volume @DEFAULT_AUDIO_SINK@
|
||||||
|
// Output: "Volume: 0.50" or "Volume: 0.50 [MUTED]"
|
||||||
|
let output = Command::new("wpctl")
|
||||||
|
.args(["get-volume", "@DEFAULT_AUDIO_SINK@"])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| VolumeError::Backend(e.to_string()))?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
debug!("wpctl get-volume output: {}", stdout.trim());
|
||||||
|
|
||||||
|
let muted = stdout.contains("[MUTED]");
|
||||||
|
|
||||||
|
// Parse "Volume: 0.50" -> 50%
|
||||||
|
let percent = stdout
|
||||||
|
.split(':')
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|s| s.split_whitespace().next())
|
||||||
|
.and_then(|s| s.parse::<f32>().ok())
|
||||||
|
.map(|v| (v * 100.0).round() as u8)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(VolumeStatus { percent, muted })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get volume status via PulseAudio
|
||||||
|
fn get_status_pulseaudio() -> VolumeResult<VolumeStatus> {
|
||||||
|
let mut status = VolumeStatus::default();
|
||||||
|
|
||||||
|
// Get default sink info
|
||||||
|
if let Ok(output) = Command::new("pactl")
|
||||||
|
.args(["get-sink-volume", "@DEFAULT_SINK@"])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
debug!("pactl get-sink-volume output: {}", stdout.trim());
|
||||||
|
|
||||||
|
// Output: "Volume: front-left: 65536 / 100% / -0.00 dB, front-right: ..."
|
||||||
|
if let Some(percent_str) = stdout.split('/').nth(1) {
|
||||||
|
if let Ok(percent) = percent_str.trim().trim_end_matches('%').parse::<u8>() {
|
||||||
|
status.percent = percent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check mute status
|
||||||
|
if let Ok(output) = Command::new("pactl")
|
||||||
|
.args(["get-sink-mute", "@DEFAULT_SINK@"])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
debug!("pactl get-sink-mute output: {}", stdout.trim());
|
||||||
|
status.muted = stdout.contains("yes");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get volume status via ALSA
|
||||||
|
fn get_status_alsa() -> VolumeResult<VolumeStatus> {
|
||||||
|
// amixer sget Master
|
||||||
|
// Output includes: "Front Left: Playback 65536 [100%] [on]"
|
||||||
|
let output = Command::new("amixer")
|
||||||
|
.args(["sget", "Master"])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| VolumeError::Backend(e.to_string()))?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
debug!("amixer sget Master output: {}", stdout);
|
||||||
|
|
||||||
|
let mut status = VolumeStatus::default();
|
||||||
|
|
||||||
|
for line in stdout.lines() {
|
||||||
|
if line.contains("Playback") && line.contains('%') {
|
||||||
|
// Extract percentage: [100%]
|
||||||
|
if let Some(start) = line.find('[') {
|
||||||
|
if let Some(end) = line[start..].find('%') {
|
||||||
|
if let Ok(percent) = line[start + 1..start + end].parse::<u8>() {
|
||||||
|
status.percent = percent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check mute status: [on] or [off]
|
||||||
|
status.muted = line.contains("[off]");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set volume via PipeWire
|
||||||
|
fn set_volume_pipewire(percent: u8) -> VolumeResult<()> {
|
||||||
|
let volume = format!("{}%", percent);
|
||||||
|
Command::new("wpctl")
|
||||||
|
.args(["set-volume", "@DEFAULT_AUDIO_SINK@", &volume])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| VolumeError::Backend(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set volume via PulseAudio
|
||||||
|
fn set_volume_pulseaudio(percent: u8) -> VolumeResult<()> {
|
||||||
|
Command::new("pactl")
|
||||||
|
.args(["set-sink-volume", "@DEFAULT_SINK@", &format!("{}%", percent)])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| VolumeError::Backend(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set volume via ALSA
|
||||||
|
fn set_volume_alsa(percent: u8) -> VolumeResult<()> {
|
||||||
|
Command::new("amixer")
|
||||||
|
.args(["sset", "Master", &format!("{}%", percent)])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| VolumeError::Backend(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle mute via PipeWire
|
||||||
|
fn toggle_mute_pipewire() -> VolumeResult<()> {
|
||||||
|
Command::new("wpctl")
|
||||||
|
.args(["set-mute", "@DEFAULT_AUDIO_SINK@", "toggle"])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| VolumeError::Backend(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle mute via PulseAudio
|
||||||
|
fn toggle_mute_pulseaudio() -> VolumeResult<()> {
|
||||||
|
Command::new("pactl")
|
||||||
|
.args(["set-sink-mute", "@DEFAULT_SINK@", "toggle"])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| VolumeError::Backend(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle mute via ALSA
|
||||||
|
fn toggle_mute_alsa() -> VolumeResult<()> {
|
||||||
|
Command::new("amixer")
|
||||||
|
.args(["sset", "Master", "toggle"])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| VolumeError::Backend(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set mute state via PipeWire
|
||||||
|
fn set_mute_pipewire(muted: bool) -> VolumeResult<()> {
|
||||||
|
let state = if muted { "1" } else { "0" };
|
||||||
|
Command::new("wpctl")
|
||||||
|
.args(["set-mute", "@DEFAULT_AUDIO_SINK@", state])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| VolumeError::Backend(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set mute state via PulseAudio
|
||||||
|
fn set_mute_pulseaudio(muted: bool) -> VolumeResult<()> {
|
||||||
|
let state = if muted { "1" } else { "0" };
|
||||||
|
Command::new("pactl")
|
||||||
|
.args(["set-sink-mute", "@DEFAULT_SINK@", state])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| VolumeError::Backend(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set mute state via ALSA
|
||||||
|
fn set_mute_alsa(muted: bool) -> VolumeResult<()> {
|
||||||
|
let state = if muted { "mute" } else { "unmute" };
|
||||||
|
Command::new("amixer")
|
||||||
|
.args(["sset", "Master", state])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| VolumeError::Backend(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LinuxVolumeController {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl VolumeController for LinuxVolumeController {
|
||||||
|
fn capabilities(&self) -> &VolumeCapabilities {
|
||||||
|
&self.capabilities
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_status(&self) -> VolumeResult<VolumeStatus> {
|
||||||
|
match self.backend {
|
||||||
|
Some(SoundBackend::PipeWire) => Self::get_status_pipewire(),
|
||||||
|
Some(SoundBackend::PulseAudio) => Self::get_status_pulseaudio(),
|
||||||
|
Some(SoundBackend::Alsa) => Self::get_status_alsa(),
|
||||||
|
None => Err(VolumeError::NotAvailable(
|
||||||
|
"No sound backend available".into(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_volume(&self, percent: u8) -> VolumeResult<()> {
|
||||||
|
if percent > self.capabilities.max_volume {
|
||||||
|
return Err(VolumeError::OutOfRange(percent));
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.backend {
|
||||||
|
Some(SoundBackend::PipeWire) => Self::set_volume_pipewire(percent),
|
||||||
|
Some(SoundBackend::PulseAudio) => Self::set_volume_pulseaudio(percent),
|
||||||
|
Some(SoundBackend::Alsa) => Self::set_volume_alsa(percent),
|
||||||
|
None => Err(VolumeError::NotAvailable(
|
||||||
|
"No sound backend available".into(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn volume_up(&self, step: u8) -> VolumeResult<()> {
|
||||||
|
let current = self.get_status().await?;
|
||||||
|
let new_volume = current.percent.saturating_add(step).min(self.capabilities.max_volume);
|
||||||
|
self.set_volume(new_volume).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn volume_down(&self, step: u8) -> VolumeResult<()> {
|
||||||
|
let current = self.get_status().await?;
|
||||||
|
let new_volume = current.percent.saturating_sub(step);
|
||||||
|
self.set_volume(new_volume).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn toggle_mute(&self) -> VolumeResult<()> {
|
||||||
|
match self.backend {
|
||||||
|
Some(SoundBackend::PipeWire) => Self::toggle_mute_pipewire(),
|
||||||
|
Some(SoundBackend::PulseAudio) => Self::toggle_mute_pulseaudio(),
|
||||||
|
Some(SoundBackend::Alsa) => Self::toggle_mute_alsa(),
|
||||||
|
None => Err(VolumeError::NotAvailable(
|
||||||
|
"No sound backend available".into(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_mute(&self, muted: bool) -> VolumeResult<()> {
|
||||||
|
match self.backend {
|
||||||
|
Some(SoundBackend::PipeWire) => Self::set_mute_pipewire(muted),
|
||||||
|
Some(SoundBackend::PulseAudio) => Self::set_mute_pulseaudio(muted),
|
||||||
|
Some(SoundBackend::Alsa) => Self::set_mute_alsa(muted),
|
||||||
|
None => Err(VolumeError::NotAvailable(
|
||||||
|
"No sound backend available".into(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_backend_name() {
|
||||||
|
assert_eq!(SoundBackend::PipeWire.name(), "pipewire");
|
||||||
|
assert_eq!(SoundBackend::PulseAudio.name(), "pulseaudio");
|
||||||
|
assert_eq!(SoundBackend::Alsa.name(), "alsa");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
use crate::battery::BatteryStatus;
|
use crate::battery::BatteryStatus;
|
||||||
use crate::state::{SessionState, SharedState};
|
use crate::state::{SessionState, SharedState};
|
||||||
use crate::time_display::TimeDisplay;
|
use crate::time_display::TimeDisplay;
|
||||||
use crate::volume::VolumeStatus;
|
|
||||||
use gtk4::glib;
|
use gtk4::glib;
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
use gtk4_layer_shell::{Edge, Layer, LayerShell};
|
use gtk4_layer_shell::{Edge, Layer, LayerShell};
|
||||||
|
|
@ -181,18 +180,71 @@ fn build_hud_content(state: SharedState) -> gtk4::Box {
|
||||||
.halign(gtk4::Align::End)
|
.halign(gtk4::Align::End)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Volume indicator
|
// Volume control with slider
|
||||||
|
let volume_box = gtk4::Box::builder()
|
||||||
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
|
.spacing(4)
|
||||||
|
.build();
|
||||||
|
volume_box.add_css_class("volume-control");
|
||||||
|
|
||||||
|
// Mute button
|
||||||
let volume_button = gtk4::Button::builder()
|
let volume_button = gtk4::Button::builder()
|
||||||
.icon_name("audio-volume-medium-symbolic")
|
.icon_name("audio-volume-medium-symbolic")
|
||||||
.has_frame(false)
|
.has_frame(false)
|
||||||
|
.tooltip_text("Toggle mute")
|
||||||
.build();
|
.build();
|
||||||
volume_button.add_css_class("indicator-button");
|
volume_button.add_css_class("indicator-button");
|
||||||
volume_button.connect_clicked(|_| {
|
volume_button.connect_clicked(|_| {
|
||||||
if let Err(e) = VolumeStatus::toggle_mute() {
|
if let Err(e) = crate::volume::toggle_mute() {
|
||||||
tracing::error!("Failed to toggle mute: {}", e);
|
tracing::error!("Failed to toggle mute: {}", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
right_box.append(&volume_button);
|
volume_box.append(&volume_button);
|
||||||
|
|
||||||
|
// Volume slider
|
||||||
|
let volume_slider = gtk4::Scale::builder()
|
||||||
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
|
.width_request(100)
|
||||||
|
.draw_value(false)
|
||||||
|
.build();
|
||||||
|
volume_slider.set_range(0.0, 100.0);
|
||||||
|
volume_slider.set_increments(5.0, 10.0);
|
||||||
|
volume_slider.add_css_class("volume-slider");
|
||||||
|
|
||||||
|
// Set initial value from daemon
|
||||||
|
if let Some(info) = crate::volume::get_volume_status() {
|
||||||
|
volume_slider.set_value(info.percent as f64);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle slider value changes
|
||||||
|
let slider_changing = std::rc::Rc::new(std::cell::Cell::new(false));
|
||||||
|
let slider_changing_clone = slider_changing.clone();
|
||||||
|
|
||||||
|
volume_slider.connect_change_value(move |slider, _, value| {
|
||||||
|
slider_changing_clone.set(true);
|
||||||
|
let percent = value.clamp(0.0, 100.0) as u8;
|
||||||
|
|
||||||
|
// Update in background thread to avoid blocking UI
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Err(e) = crate::volume::set_volume(percent) {
|
||||||
|
tracing::error!("Failed to set volume: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow the slider to update
|
||||||
|
slider.set_value(value);
|
||||||
|
glib::Propagation::Stop
|
||||||
|
});
|
||||||
|
|
||||||
|
volume_box.append(&volume_slider);
|
||||||
|
|
||||||
|
// Volume percentage label
|
||||||
|
let volume_label = gtk4::Label::new(Some("--%"));
|
||||||
|
volume_label.add_css_class("volume-label");
|
||||||
|
volume_label.set_width_chars(4);
|
||||||
|
volume_box.append(&volume_label);
|
||||||
|
|
||||||
|
right_box.append(&volume_box);
|
||||||
|
|
||||||
// Battery indicator
|
// Battery indicator
|
||||||
let battery_box = gtk4::Box::builder()
|
let battery_box = gtk4::Box::builder()
|
||||||
|
|
@ -258,6 +310,9 @@ fn build_hud_content(state: SharedState) -> gtk4::Box {
|
||||||
let battery_icon_clone = battery_icon.clone();
|
let battery_icon_clone = battery_icon.clone();
|
||||||
let battery_label_clone = battery_label.clone();
|
let battery_label_clone = battery_label.clone();
|
||||||
let volume_button_clone = volume_button.clone();
|
let volume_button_clone = volume_button.clone();
|
||||||
|
let volume_slider_clone = volume_slider.clone();
|
||||||
|
let volume_label_clone = volume_label.clone();
|
||||||
|
let slider_changing_for_update = slider_changing.clone();
|
||||||
|
|
||||||
glib::timeout_add_local(Duration::from_millis(500), move || {
|
glib::timeout_add_local(Duration::from_millis(500), move || {
|
||||||
// Update session state
|
// Update session state
|
||||||
|
|
@ -316,9 +371,31 @@ fn build_hud_content(state: SharedState) -> gtk4::Box {
|
||||||
battery_label_clone.set_text("--%");
|
battery_label_clone.set_text("--%");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update volume
|
// Update volume from daemon
|
||||||
let volume = VolumeStatus::read();
|
if let Some(volume) = crate::volume::get_volume_status() {
|
||||||
volume_button_clone.set_icon_name(volume.icon_name());
|
volume_button_clone.set_icon_name(volume.icon_name());
|
||||||
|
volume_label_clone.set_text(&format!("{}%", volume.percent));
|
||||||
|
|
||||||
|
// Only update slider if user is not actively dragging it
|
||||||
|
if !slider_changing_for_update.get() {
|
||||||
|
volume_slider_clone.set_value(volume.percent as f64);
|
||||||
|
}
|
||||||
|
// Reset the changing flag after a short delay
|
||||||
|
slider_changing_for_update.set(false);
|
||||||
|
|
||||||
|
// Disable slider when muted or when restrictions don't allow changes
|
||||||
|
let slider_enabled = !volume.muted && volume.restrictions.allow_change;
|
||||||
|
volume_slider_clone.set_sensitive(slider_enabled);
|
||||||
|
volume_button_clone.set_sensitive(volume.restrictions.allow_mute);
|
||||||
|
|
||||||
|
// Update slider range based on restrictions
|
||||||
|
let min = volume.restrictions.min_volume.unwrap_or(0) as f64;
|
||||||
|
let max = volume.restrictions.max_volume.unwrap_or(100) as f64;
|
||||||
|
volume_slider_clone.set_range(min, max);
|
||||||
|
} else {
|
||||||
|
volume_label_clone.set_text("--%");
|
||||||
|
volume_slider_clone.set_sensitive(false);
|
||||||
|
}
|
||||||
|
|
||||||
glib::ControlFlow::Continue
|
glib::ControlFlow::Continue
|
||||||
});
|
});
|
||||||
|
|
@ -416,6 +493,48 @@ fn load_css() {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.volume-control {
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider {
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider trough {
|
||||||
|
min-height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider highlight {
|
||||||
|
min-height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background-color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider slider {
|
||||||
|
min-width: 12px;
|
||||||
|
min-height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider:disabled trough {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider:disabled highlight {
|
||||||
|
background-color: rgba(136, 192, 208, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
min-width: 3em;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
let provider = gtk4::CssProvider::new();
|
let provider = gtk4::CssProvider::new();
|
||||||
|
|
|
||||||
|
|
@ -1,131 +1,181 @@
|
||||||
//! Volume monitoring and control module
|
//! Volume monitoring and control module
|
||||||
//!
|
//!
|
||||||
//! Monitors and controls system volume via PulseAudio/PipeWire.
|
//! Provides volume status and control via the shepherdd daemon.
|
||||||
//! Uses the `pactl` command-line tool for simplicity.
|
//! The daemon handles actual volume control and enforces restrictions.
|
||||||
|
|
||||||
use std::process::Command;
|
use shepherd_api::{Command, ResponsePayload, VolumeInfo};
|
||||||
|
use shepherd_ipc::IpcClient;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tokio::runtime::Runtime;
|
||||||
|
|
||||||
/// Volume status
|
/// Get the default socket path from environment or fallback
|
||||||
#[derive(Debug, Clone, Default)]
|
fn get_socket_path() -> PathBuf {
|
||||||
pub struct VolumeStatus {
|
std::env::var("SHEPHERD_SOCKET")
|
||||||
/// Volume percentage (0-100+)
|
.map(PathBuf::from)
|
||||||
pub percent: u8,
|
.unwrap_or_else(|_| PathBuf::from("./dev-runtime/shepherd.sock"))
|
||||||
/// Whether audio is muted
|
|
||||||
pub muted: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VolumeStatus {
|
/// Get current volume status from the daemon
|
||||||
/// Read volume status using pactl
|
pub fn get_volume_status() -> Option<VolumeInfo> {
|
||||||
pub fn read() -> Self {
|
let socket_path = get_socket_path();
|
||||||
let mut status = VolumeStatus::default();
|
|
||||||
|
|
||||||
// Get default sink info
|
let rt = match Runtime::new() {
|
||||||
if let Ok(output) = Command::new("pactl")
|
Ok(rt) => rt,
|
||||||
.args(["get-sink-volume", "@DEFAULT_SINK@"])
|
Err(e) => {
|
||||||
.output()
|
tracing::error!("Failed to create runtime: {}", e);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rt.block_on(async {
|
||||||
|
match IpcClient::connect(&socket_path).await {
|
||||||
|
Ok(mut client) => match client.send(Command::GetVolume).await {
|
||||||
|
Ok(response) => {
|
||||||
|
if let shepherd_api::ResponseResult::Ok(ResponsePayload::Volume(info)) =
|
||||||
|
response.result
|
||||||
{
|
{
|
||||||
if let Ok(stdout) = String::from_utf8(output.stdout) {
|
Some(info)
|
||||||
// Output looks like: "Volume: front-left: 65536 / 100% / -0.00 dB, front-right: ..."
|
|
||||||
if let Some(percent_str) = stdout.split('/').nth(1) {
|
|
||||||
if let Ok(percent) = percent_str.trim().trim_end_matches('%').parse::<u8>() {
|
|
||||||
status.percent = percent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check mute status
|
|
||||||
if let Ok(output) = Command::new("pactl")
|
|
||||||
.args(["get-sink-mute", "@DEFAULT_SINK@"])
|
|
||||||
.output()
|
|
||||||
{
|
|
||||||
if let Ok(stdout) = String::from_utf8(output.stdout) {
|
|
||||||
// Output looks like: "Mute: yes" or "Mute: no"
|
|
||||||
status.muted = stdout.contains("yes");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
status
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Toggle mute state
|
|
||||||
pub fn toggle_mute() -> anyhow::Result<()> {
|
|
||||||
Command::new("pactl")
|
|
||||||
.args(["set-sink-mute", "@DEFAULT_SINK@", "toggle"])
|
|
||||||
.status()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Increase volume by a step
|
|
||||||
pub fn volume_up(step: u8) -> anyhow::Result<()> {
|
|
||||||
Command::new("pactl")
|
|
||||||
.args([
|
|
||||||
"set-sink-volume",
|
|
||||||
"@DEFAULT_SINK@",
|
|
||||||
&format!("+{}%", step),
|
|
||||||
])
|
|
||||||
.status()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decrease volume by a step
|
|
||||||
pub fn volume_down(step: u8) -> anyhow::Result<()> {
|
|
||||||
Command::new("pactl")
|
|
||||||
.args([
|
|
||||||
"set-sink-volume",
|
|
||||||
"@DEFAULT_SINK@",
|
|
||||||
&format!("-{}%", step),
|
|
||||||
])
|
|
||||||
.status()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set volume to a specific percentage
|
|
||||||
pub fn set_volume(percent: u8) -> anyhow::Result<()> {
|
|
||||||
Command::new("pactl")
|
|
||||||
.args(["set-sink-volume", "@DEFAULT_SINK@", &format!("{}%", percent)])
|
|
||||||
.status()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get an icon name for the current volume status
|
|
||||||
pub fn icon_name(&self) -> &'static str {
|
|
||||||
if self.muted {
|
|
||||||
"audio-volume-muted-symbolic"
|
|
||||||
} else if self.percent == 0 {
|
|
||||||
"audio-volume-muted-symbolic"
|
|
||||||
} else if self.percent < 33 {
|
|
||||||
"audio-volume-low-symbolic"
|
|
||||||
} else if self.percent < 66 {
|
|
||||||
"audio-volume-medium-symbolic"
|
|
||||||
} else {
|
} else {
|
||||||
"audio-volume-high-symbolic"
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to get volume: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!("Failed to connect to daemon for volume: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle mute state via the daemon
|
||||||
|
pub fn toggle_mute() -> anyhow::Result<()> {
|
||||||
|
let socket_path = get_socket_path();
|
||||||
|
|
||||||
|
let rt = Runtime::new()?;
|
||||||
|
|
||||||
|
rt.block_on(async {
|
||||||
|
let mut client = IpcClient::connect(&socket_path).await?;
|
||||||
|
let response = client.send(Command::ToggleMute).await?;
|
||||||
|
|
||||||
|
match response.result {
|
||||||
|
shepherd_api::ResponseResult::Ok(ResponsePayload::VolumeSet) => Ok(()),
|
||||||
|
shepherd_api::ResponseResult::Ok(ResponsePayload::VolumeDenied { reason }) => {
|
||||||
|
Err(anyhow::anyhow!("Volume denied: {}", reason))
|
||||||
|
}
|
||||||
|
shepherd_api::ResponseResult::Err(e) => {
|
||||||
|
Err(anyhow::anyhow!("Error: {}", e.message))
|
||||||
|
}
|
||||||
|
_ => Err(anyhow::anyhow!("Unexpected response")),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Increase volume by a step via the daemon
|
||||||
|
pub fn volume_up(step: u8) -> anyhow::Result<()> {
|
||||||
|
let socket_path = get_socket_path();
|
||||||
|
|
||||||
|
let rt = Runtime::new()?;
|
||||||
|
|
||||||
|
rt.block_on(async {
|
||||||
|
let mut client = IpcClient::connect(&socket_path).await?;
|
||||||
|
let response = client.send(Command::VolumeUp { step }).await?;
|
||||||
|
|
||||||
|
match response.result {
|
||||||
|
shepherd_api::ResponseResult::Ok(ResponsePayload::VolumeSet) => Ok(()),
|
||||||
|
shepherd_api::ResponseResult::Ok(ResponsePayload::VolumeDenied { reason }) => {
|
||||||
|
Err(anyhow::anyhow!("Volume denied: {}", reason))
|
||||||
|
}
|
||||||
|
shepherd_api::ResponseResult::Err(e) => {
|
||||||
|
Err(anyhow::anyhow!("Error: {}", e.message))
|
||||||
|
}
|
||||||
|
_ => Err(anyhow::anyhow!("Unexpected response")),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrease volume by a step via the daemon
|
||||||
|
pub fn volume_down(step: u8) -> anyhow::Result<()> {
|
||||||
|
let socket_path = get_socket_path();
|
||||||
|
|
||||||
|
let rt = Runtime::new()?;
|
||||||
|
|
||||||
|
rt.block_on(async {
|
||||||
|
let mut client = IpcClient::connect(&socket_path).await?;
|
||||||
|
let response = client.send(Command::VolumeDown { step }).await?;
|
||||||
|
|
||||||
|
match response.result {
|
||||||
|
shepherd_api::ResponseResult::Ok(ResponsePayload::VolumeSet) => Ok(()),
|
||||||
|
shepherd_api::ResponseResult::Ok(ResponsePayload::VolumeDenied { reason }) => {
|
||||||
|
Err(anyhow::anyhow!("Volume denied: {}", reason))
|
||||||
|
}
|
||||||
|
shepherd_api::ResponseResult::Err(e) => {
|
||||||
|
Err(anyhow::anyhow!("Error: {}", e.message))
|
||||||
|
}
|
||||||
|
_ => Err(anyhow::anyhow!("Unexpected response")),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set volume to a specific percentage via the daemon
|
||||||
|
pub fn set_volume(percent: u8) -> anyhow::Result<()> {
|
||||||
|
let socket_path = get_socket_path();
|
||||||
|
|
||||||
|
let rt = Runtime::new()?;
|
||||||
|
|
||||||
|
rt.block_on(async {
|
||||||
|
let mut client = IpcClient::connect(&socket_path).await?;
|
||||||
|
let response = client.send(Command::SetVolume { percent }).await?;
|
||||||
|
|
||||||
|
match response.result {
|
||||||
|
shepherd_api::ResponseResult::Ok(ResponsePayload::VolumeSet) => Ok(()),
|
||||||
|
shepherd_api::ResponseResult::Ok(ResponsePayload::VolumeDenied { reason }) => {
|
||||||
|
Err(anyhow::anyhow!("Volume denied: {}", reason))
|
||||||
|
}
|
||||||
|
shepherd_api::ResponseResult::Err(e) => {
|
||||||
|
Err(anyhow::anyhow!("Error: {}", e.message))
|
||||||
|
}
|
||||||
|
_ => Err(anyhow::anyhow!("Unexpected response")),
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use shepherd_api::VolumeRestrictions;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_volume_icon_names() {
|
fn test_volume_icon_names() {
|
||||||
let status = VolumeStatus {
|
// Test that VolumeInfo::icon_name works correctly
|
||||||
|
let info = shepherd_api::VolumeInfo {
|
||||||
percent: 0,
|
percent: 0,
|
||||||
muted: false,
|
muted: false,
|
||||||
|
available: true,
|
||||||
|
backend: Some("test".into()),
|
||||||
|
restrictions: VolumeRestrictions::unrestricted(),
|
||||||
};
|
};
|
||||||
assert_eq!(status.icon_name(), "audio-volume-muted-symbolic");
|
assert_eq!(info.icon_name(), "audio-volume-muted-symbolic");
|
||||||
|
|
||||||
let status = VolumeStatus {
|
let info = shepherd_api::VolumeInfo {
|
||||||
percent: 50,
|
percent: 50,
|
||||||
muted: false,
|
muted: false,
|
||||||
|
available: true,
|
||||||
|
backend: Some("test".into()),
|
||||||
|
restrictions: VolumeRestrictions::unrestricted(),
|
||||||
};
|
};
|
||||||
assert_eq!(status.icon_name(), "audio-volume-medium-symbolic");
|
assert_eq!(info.icon_name(), "audio-volume-medium-symbolic");
|
||||||
|
|
||||||
let status = VolumeStatus {
|
let info = shepherd_api::VolumeInfo {
|
||||||
percent: 100,
|
percent: 100,
|
||||||
muted: true,
|
muted: true,
|
||||||
|
available: true,
|
||||||
|
backend: Some("test".into()),
|
||||||
|
restrictions: VolumeRestrictions::unrestricted(),
|
||||||
};
|
};
|
||||||
assert_eq!(status.icon_name(), "audio-volume-muted-symbolic");
|
assert_eq!(info.icon_name(), "audio-volume-muted-symbolic");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,9 @@ impl SharedState {
|
||||||
EventPayload::AuditEntry { .. } => {
|
EventPayload::AuditEntry { .. } => {
|
||||||
// Audit events are for admin clients, ignore
|
// Audit events are for admin clients, ignore
|
||||||
}
|
}
|
||||||
|
EventPayload::VolumeChanged { .. } => {
|
||||||
|
// Volume events are handled by HUD
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,20 @@
|
||||||
//! - Core engine
|
//! - Core engine
|
||||||
//! - Host adapter (Linux)
|
//! - Host adapter (Linux)
|
||||||
//! - IPC server
|
//! - IPC server
|
||||||
|
//! - Volume control
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use chrono::Local;
|
use chrono::Local;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use shepherd_api::{
|
use shepherd_api::{
|
||||||
Command, DaemonStateSnapshot, ErrorCode, ErrorInfo, Event, EventPayload, HealthStatus,
|
Command, DaemonStateSnapshot, ErrorCode, ErrorInfo, Event, EventPayload, HealthStatus,
|
||||||
Response, ResponsePayload, SessionEndReason, StopMode, API_VERSION,
|
Response, ResponsePayload, SessionEndReason, StopMode, VolumeInfo, VolumeRestrictions,
|
||||||
|
API_VERSION,
|
||||||
};
|
};
|
||||||
use shepherd_config::{load_config, Policy};
|
use shepherd_config::{load_config, Policy, VolumePolicy};
|
||||||
use shepherd_core::{CoreEngine, CoreEvent, LaunchDecision, StopDecision};
|
use shepherd_core::{CoreEngine, CoreEvent, LaunchDecision, StopDecision};
|
||||||
use shepherd_host_api::{HostAdapter, HostEvent, StopMode as HostStopMode};
|
use shepherd_host_api::{HostAdapter, HostEvent, StopMode as HostStopMode, VolumeController};
|
||||||
use shepherd_host_linux::LinuxHost;
|
use shepherd_host_linux::{LinuxHost, LinuxVolumeController};
|
||||||
use shepherd_ipc::{IpcServer, ServerMessage};
|
use shepherd_ipc::{IpcServer, ServerMessage};
|
||||||
use shepherd_store::{AuditEvent, AuditEventType, SqliteStore, Store};
|
use shepherd_store::{AuditEvent, AuditEventType, SqliteStore, Store};
|
||||||
use shepherd_util::{ClientId, EntryId, MonotonicInstant, RateLimiter};
|
use shepherd_util::{ClientId, EntryId, MonotonicInstant, RateLimiter};
|
||||||
|
|
@ -55,6 +57,7 @@ struct Args {
|
||||||
struct Daemon {
|
struct Daemon {
|
||||||
engine: CoreEngine,
|
engine: CoreEngine,
|
||||||
host: Arc<LinuxHost>,
|
host: Arc<LinuxHost>,
|
||||||
|
volume: Arc<LinuxVolumeController>,
|
||||||
ipc: Arc<IpcServer>,
|
ipc: Arc<IpcServer>,
|
||||||
store: Arc<dyn Store>,
|
store: Arc<dyn Store>,
|
||||||
rate_limiter: RateLimiter,
|
rate_limiter: RateLimiter,
|
||||||
|
|
@ -102,6 +105,17 @@ impl Daemon {
|
||||||
// Initialize host adapter
|
// Initialize host adapter
|
||||||
let host = Arc::new(LinuxHost::new());
|
let host = Arc::new(LinuxHost::new());
|
||||||
|
|
||||||
|
// Initialize volume controller
|
||||||
|
let volume = Arc::new(LinuxVolumeController::new());
|
||||||
|
if volume.capabilities().available {
|
||||||
|
info!(
|
||||||
|
backend = ?volume.capabilities().backend,
|
||||||
|
"Volume controller initialized"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
warn!("No sound backend detected, volume control unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize core engine
|
// Initialize core engine
|
||||||
let engine = CoreEngine::new(policy, store.clone(), host.capabilities().clone());
|
let engine = CoreEngine::new(policy, store.clone(), host.capabilities().clone());
|
||||||
|
|
||||||
|
|
@ -117,6 +131,7 @@ impl Daemon {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
engine,
|
engine,
|
||||||
host,
|
host,
|
||||||
|
volume,
|
||||||
ipc: Arc::new(ipc),
|
ipc: Arc::new(ipc),
|
||||||
store,
|
store,
|
||||||
rate_limiter,
|
rate_limiter,
|
||||||
|
|
@ -139,6 +154,7 @@ impl Daemon {
|
||||||
let engine = Arc::new(Mutex::new(self.engine));
|
let engine = Arc::new(Mutex::new(self.engine));
|
||||||
let rate_limiter = Arc::new(Mutex::new(self.rate_limiter));
|
let rate_limiter = Arc::new(Mutex::new(self.rate_limiter));
|
||||||
let host = self.host.clone();
|
let host = self.host.clone();
|
||||||
|
let volume = self.volume.clone();
|
||||||
let store = self.store.clone();
|
let store = self.store.clone();
|
||||||
|
|
||||||
// Spawn IPC accept task
|
// Spawn IPC accept task
|
||||||
|
|
@ -179,7 +195,7 @@ impl Daemon {
|
||||||
|
|
||||||
// IPC messages
|
// IPC messages
|
||||||
Some(msg) = ipc_messages.recv() => {
|
Some(msg) = ipc_messages.recv() => {
|
||||||
Self::handle_ipc_message(&engine, &host, &ipc_ref, &store, &rate_limiter, msg).await;
|
Self::handle_ipc_message(&engine, &host, &volume, &ipc_ref, &store, &rate_limiter, msg).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -367,6 +383,7 @@ impl Daemon {
|
||||||
async fn handle_ipc_message(
|
async fn handle_ipc_message(
|
||||||
engine: &Arc<Mutex<CoreEngine>>,
|
engine: &Arc<Mutex<CoreEngine>>,
|
||||||
host: &Arc<LinuxHost>,
|
host: &Arc<LinuxHost>,
|
||||||
|
volume: &Arc<LinuxVolumeController>,
|
||||||
ipc: &Arc<IpcServer>,
|
ipc: &Arc<IpcServer>,
|
||||||
store: &Arc<dyn Store>,
|
store: &Arc<dyn Store>,
|
||||||
rate_limiter: &Arc<Mutex<RateLimiter>>,
|
rate_limiter: &Arc<Mutex<RateLimiter>>,
|
||||||
|
|
@ -388,7 +405,7 @@ impl Daemon {
|
||||||
}
|
}
|
||||||
|
|
||||||
let response =
|
let response =
|
||||||
Self::handle_command(engine, host, ipc, store, &client_id, request.request_id, request.command)
|
Self::handle_command(engine, host, volume, ipc, store, &client_id, request.request_id, request.command)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let _ = ipc.send_response(&client_id, response).await;
|
let _ = ipc.send_response(&client_id, response).await;
|
||||||
|
|
@ -430,6 +447,7 @@ impl Daemon {
|
||||||
async fn handle_command(
|
async fn handle_command(
|
||||||
engine: &Arc<Mutex<CoreEngine>>,
|
engine: &Arc<Mutex<CoreEngine>>,
|
||||||
host: &Arc<LinuxHost>,
|
host: &Arc<LinuxHost>,
|
||||||
|
volume: &Arc<LinuxVolumeController>,
|
||||||
ipc: &Arc<IpcServer>,
|
ipc: &Arc<IpcServer>,
|
||||||
store: &Arc<dyn Store>,
|
store: &Arc<dyn Store>,
|
||||||
client_id: &ClientId,
|
client_id: &ClientId,
|
||||||
|
|
@ -676,9 +694,233 @@ impl Daemon {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Command::GetVolume => {
|
||||||
|
let restrictions = Self::get_current_volume_restrictions(&engine).await;
|
||||||
|
|
||||||
|
match volume.get_status().await {
|
||||||
|
Ok(status) => {
|
||||||
|
let info = VolumeInfo {
|
||||||
|
percent: status.percent,
|
||||||
|
muted: status.muted,
|
||||||
|
available: volume.capabilities().available,
|
||||||
|
backend: volume.capabilities().backend.clone(),
|
||||||
|
restrictions,
|
||||||
|
};
|
||||||
|
Response::success(request_id, ResponsePayload::Volume(info))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let info = VolumeInfo {
|
||||||
|
percent: 0,
|
||||||
|
muted: false,
|
||||||
|
available: false,
|
||||||
|
backend: None,
|
||||||
|
restrictions,
|
||||||
|
};
|
||||||
|
warn!(error = %e, "Failed to get volume status");
|
||||||
|
Response::success(request_id, ResponsePayload::Volume(info))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::SetVolume { percent } => {
|
||||||
|
let restrictions = Self::get_current_volume_restrictions(&engine).await;
|
||||||
|
|
||||||
|
if !restrictions.allow_change {
|
||||||
|
return Response::success(
|
||||||
|
request_id,
|
||||||
|
ResponsePayload::VolumeDenied {
|
||||||
|
reason: "Volume changes are not allowed".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let clamped = restrictions.clamp_volume(percent);
|
||||||
|
|
||||||
|
match volume.set_volume(clamped).await {
|
||||||
|
Ok(()) => {
|
||||||
|
// Broadcast volume change
|
||||||
|
if let Ok(status) = volume.get_status().await {
|
||||||
|
ipc.broadcast_event(Event::new(EventPayload::VolumeChanged {
|
||||||
|
percent: status.percent,
|
||||||
|
muted: status.muted,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Response::success(request_id, ResponsePayload::VolumeSet)
|
||||||
|
}
|
||||||
|
Err(e) => Response::success(
|
||||||
|
request_id,
|
||||||
|
ResponsePayload::VolumeDenied {
|
||||||
|
reason: e.to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::VolumeUp { step } => {
|
||||||
|
let restrictions = Self::get_current_volume_restrictions(&engine).await;
|
||||||
|
|
||||||
|
if !restrictions.allow_change {
|
||||||
|
return Response::success(
|
||||||
|
request_id,
|
||||||
|
ResponsePayload::VolumeDenied {
|
||||||
|
reason: "Volume changes are not allowed".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current volume and check if we'd exceed max
|
||||||
|
let current = volume.get_status().await.map(|s| s.percent).unwrap_or(0);
|
||||||
|
let target = current.saturating_add(step);
|
||||||
|
let clamped = restrictions.clamp_volume(target);
|
||||||
|
|
||||||
|
match volume.set_volume(clamped).await {
|
||||||
|
Ok(()) => {
|
||||||
|
if let Ok(status) = volume.get_status().await {
|
||||||
|
ipc.broadcast_event(Event::new(EventPayload::VolumeChanged {
|
||||||
|
percent: status.percent,
|
||||||
|
muted: status.muted,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Response::success(request_id, ResponsePayload::VolumeSet)
|
||||||
|
}
|
||||||
|
Err(e) => Response::success(
|
||||||
|
request_id,
|
||||||
|
ResponsePayload::VolumeDenied {
|
||||||
|
reason: e.to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::VolumeDown { step } => {
|
||||||
|
let restrictions = Self::get_current_volume_restrictions(&engine).await;
|
||||||
|
|
||||||
|
if !restrictions.allow_change {
|
||||||
|
return Response::success(
|
||||||
|
request_id,
|
||||||
|
ResponsePayload::VolumeDenied {
|
||||||
|
reason: "Volume changes are not allowed".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current volume and check if we'd go below min
|
||||||
|
let current = volume.get_status().await.map(|s| s.percent).unwrap_or(0);
|
||||||
|
let target = current.saturating_sub(step);
|
||||||
|
let clamped = restrictions.clamp_volume(target);
|
||||||
|
|
||||||
|
match volume.set_volume(clamped).await {
|
||||||
|
Ok(()) => {
|
||||||
|
if let Ok(status) = volume.get_status().await {
|
||||||
|
ipc.broadcast_event(Event::new(EventPayload::VolumeChanged {
|
||||||
|
percent: status.percent,
|
||||||
|
muted: status.muted,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Response::success(request_id, ResponsePayload::VolumeSet)
|
||||||
|
}
|
||||||
|
Err(e) => Response::success(
|
||||||
|
request_id,
|
||||||
|
ResponsePayload::VolumeDenied {
|
||||||
|
reason: e.to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::ToggleMute => {
|
||||||
|
let restrictions = Self::get_current_volume_restrictions(&engine).await;
|
||||||
|
|
||||||
|
if !restrictions.allow_mute {
|
||||||
|
return Response::success(
|
||||||
|
request_id,
|
||||||
|
ResponsePayload::VolumeDenied {
|
||||||
|
reason: "Mute toggle is not allowed".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
match volume.toggle_mute().await {
|
||||||
|
Ok(()) => {
|
||||||
|
if let Ok(status) = volume.get_status().await {
|
||||||
|
ipc.broadcast_event(Event::new(EventPayload::VolumeChanged {
|
||||||
|
percent: status.percent,
|
||||||
|
muted: status.muted,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Response::success(request_id, ResponsePayload::VolumeSet)
|
||||||
|
}
|
||||||
|
Err(e) => Response::success(
|
||||||
|
request_id,
|
||||||
|
ResponsePayload::VolumeDenied {
|
||||||
|
reason: e.to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Command::SetMute { muted } => {
|
||||||
|
let restrictions = Self::get_current_volume_restrictions(&engine).await;
|
||||||
|
|
||||||
|
if !restrictions.allow_mute {
|
||||||
|
return Response::success(
|
||||||
|
request_id,
|
||||||
|
ResponsePayload::VolumeDenied {
|
||||||
|
reason: "Mute toggle is not allowed".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
match volume.set_mute(muted).await {
|
||||||
|
Ok(()) => {
|
||||||
|
if let Ok(status) = volume.get_status().await {
|
||||||
|
ipc.broadcast_event(Event::new(EventPayload::VolumeChanged {
|
||||||
|
percent: status.percent,
|
||||||
|
muted: status.muted,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Response::success(request_id, ResponsePayload::VolumeSet)
|
||||||
|
}
|
||||||
|
Err(e) => Response::success(
|
||||||
|
request_id,
|
||||||
|
ResponsePayload::VolumeDenied {
|
||||||
|
reason: e.to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Command::Ping => Response::success(request_id, ResponsePayload::Pong),
|
Command::Ping => Response::success(request_id, ResponsePayload::Pong),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the current volume restrictions based on policy and active session
|
||||||
|
async fn get_current_volume_restrictions(
|
||||||
|
engine: &Arc<Mutex<CoreEngine>>,
|
||||||
|
) -> VolumeRestrictions {
|
||||||
|
let eng = engine.lock().await;
|
||||||
|
|
||||||
|
// Check if there's an active session with volume restrictions
|
||||||
|
if let Some(session) = eng.current_session() {
|
||||||
|
if let Some(entry) = eng.policy().get_entry(&session.plan.entry_id) {
|
||||||
|
if let Some(ref vol_policy) = entry.volume {
|
||||||
|
return Self::convert_volume_policy(vol_policy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to global policy
|
||||||
|
Self::convert_volume_policy(&eng.policy().volume)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_volume_policy(policy: &VolumePolicy) -> VolumeRestrictions {
|
||||||
|
VolumeRestrictions {
|
||||||
|
max_volume: policy.max_volume,
|
||||||
|
min_volume: policy.min_volume,
|
||||||
|
allow_mute: policy.allow_mute,
|
||||||
|
allow_change: policy.allow_change,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|
|
||||||
|
|
@ -48,12 +48,14 @@ fn make_test_policy() -> Policy {
|
||||||
message_template: Some("2 seconds left!".into()),
|
message_template: Some("2 seconds left!".into()),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
volume: None,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
disabled_reason: None,
|
disabled_reason: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
default_warnings: vec![],
|
default_warnings: vec![],
|
||||||
default_max_run: Some(Duration::from_secs(3600)),
|
default_max_run: Some(Duration::from_secs(3600)),
|
||||||
|
volume: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue