use std::collections::HashMap;
use std::future::Future;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock, Mutex, PoisonError};
use std::time::Duration;
use openlogi_core::color::Rgb;
use openlogi_device::write::LightingWrite;
use tokio::sync::oneshot;
use tracing::{debug, warn};
use crate::{DeviceRoute, HidppOperation, LightingMethod, SharedChannel, WriteError};
const WAIT_TIMEOUT: Duration = Duration::from_secs(5);
type RouteLock = Arc<tokio::sync::Mutex<()>>;
static LIGHTING_LOCKS: LazyLock<Mutex<HashMap<String, RouteLock>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
#[cfg(test)]
mod tests;
pub struct LightingCancellation(Arc<AtomicBool>);
impl LightingCancellation {
#[must_use]
pub fn is_cancelled(&self) -> bool {
self.0.load(Ordering::Acquire)
}
}
#[must_use = "wait for lighting or explicitly detach it"]
pub struct LightingJob {
cancel: Option<Arc<AtomicBool>>,
result: oneshot::Receiver<Result<(), WriteError>>,
}
impl LightingJob {
pub fn spawn<F, Fut>(route: &DeviceRoute, operation: F) -> Result<Self, WriteError>
where
F: FnOnce(LightingCancellation) -> Fut + Send + 'static,
Fut: Future<Output = Result<(), WriteError>>,
{
let lock = LIGHTING_LOCKS
.lock()
.unwrap_or_else(PoisonError::into_inner)
.entry(route.to_string())
.or_default()
.clone();
let cancel = Arc::new(AtomicBool::new(false));
let cancellation = LightingCancellation(Arc::clone(&cancel));
let (sender, result) = oneshot::channel();
let route = route.clone();
std::thread::Builder::new()
.name("openlogi-rgb".into())
.spawn(move || {
let result = match openlogi_core::worker::runtime() {
Ok(runtime) => runtime.block_on(async {
let _guard = tokio::time::timeout(WAIT_TIMEOUT, lock.lock_owned())
.await
.map_err(|_| timed_out())?;
if cancellation.is_cancelled() {
return Err(timed_out());
}
operation(cancellation).await
}),
Err(error) => Err(WriteError::RuntimeInit {
message: error.to_string(),
}),
};
match &result {
Ok(()) => debug!(%route, "lighting transaction completed"),
Err(error) => warn!(%route, ?error, "lighting transaction failed"),
}
let _ = sender.send(result);
})
.map_err(|error| {
WriteError::Hid(format!("could not start lighting worker: {error}"))
})?;
Ok(Self {
cancel: Some(cancel),
result,
})
}
pub async fn wait(mut self) -> Result<(), WriteError> {
tokio::time::timeout(WAIT_TIMEOUT, &mut self.result)
.await
.map_err(|_| timed_out())?
.map_err(|_| WriteError::AgentUnavailable)?
}
pub async fn finish(mut self) -> Result<(), WriteError> {
(&mut self.result)
.await
.map_err(|_| WriteError::AgentUnavailable)?
}
pub fn detach(mut self) {
self.cancel = None;
}
}
impl Drop for LightingJob {
fn drop(&mut self) {
if let Some(cancel) = &self.cancel {
cancel.store(true, Ordering::Release);
}
}
}
fn timed_out() -> WriteError {
WriteError::RequestTimedOut {
operation: HidppOperation::Lighting,
}
}
pub async fn set_keyboard_color_on(
shared: &SharedChannel,
r: u8,
g: u8,
b: u8,
) -> Result<(), WriteError> {
set_keyboard_color_with_on(shared, LightingMethod::Auto, r, g, b).await
}
pub async fn set_keyboard_color_with_on(
shared: &SharedChannel,
method: LightingMethod,
r: u8,
g: u8,
b: u8,
) -> Result<(), WriteError> {
let channel = shared.clone();
let gate = crate::host::device_io_gate();
LightingJob::spawn(shared.route(), move |cancel| async move {
LightingWrite {
method,
color: Rgb::new(r, g, b),
}
.apply_on(&channel, || cancel.is_cancelled(), || gate.allows_io())
.await
})?
.finish()
.await
}