use std::io::{self, Write as _};
use std::num::NonZeroU8;
use std::process::ExitCode;
use anyhow::Result;
use clap::{Args, Subcommand, ValueEnum};
use openlogi_core::hid::{Dpi, SmartShiftMode, WriteError};
use openlogi_ipc::{AgentClient, AgentSnapshot};
use serde_json::{Value, json};
use tarpc::context;
use crate::agent;
mod output;
use output::{ApiError, devices, envelope, inventory_health, select_device};
#[derive(Debug, Subcommand)]
pub enum ApiCommand {
Status,
Devices,
Dpi {
#[arg(long)]
device: String,
#[arg(long)]
set: Option<u16>,
},
Smartshift(SmartshiftArgs),
FnLock {
#[arg(long)]
device: String,
#[arg(long, value_enum)]
set: Option<Switch>,
},
}
#[derive(Debug, Args)]
pub struct SmartshiftArgs {
#[arg(long)]
device: String,
#[arg(long, value_enum)]
mode: Option<WheelMode>,
#[arg(long)]
auto_disengage: Option<NonZeroU8>,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum WheelMode {
Free,
Ratchet,
}
impl From<WheelMode> for SmartShiftMode {
fn from(mode: WheelMode) -> Self {
match mode {
WheelMode::Free => Self::Free,
WheelMode::Ratchet => Self::Ratchet,
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum Switch {
On,
Off,
}
pub async fn run(command: ApiCommand) -> Result<ExitCode> {
let result = match agent::connect().await {
Ok(client) => execute(&client, command).await,
Err(error) => Err(ApiError::from(error)),
};
let success = result.is_ok();
let mut stdout = io::stdout().lock();
serde_json::to_writer(&mut stdout, &envelope(result))?;
writeln!(stdout)?;
Ok(if success {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
})
}
async fn execute(client: &AgentClient, command: ApiCommand) -> Result<Value, ApiError> {
let snapshot = agent::call(client.snapshot(context::current())).await?;
match command {
ApiCommand::Status => Ok(json!({
"agent_version": snapshot.status.agent_version,
"inventory": inventory_health(snapshot.status.inventory),
"accessibility_granted": snapshot.status.accessibility_granted,
"input_monitoring_granted": snapshot.status.input_monitoring_granted,
"hook_installed": snapshot.status.hook_installed,
"hid_open_failures": snapshot.status.hid_open_failures,
"launch_at_login": snapshot.status.launch_at_login,
})),
ApiCommand::Devices => Ok(json!({
"inventory": inventory_health(snapshot.status.inventory),
"devices": devices(&snapshot),
})),
ApiCommand::Dpi { device, set } => dpi(client, &snapshot, &device, set).await,
ApiCommand::Smartshift(args) => smartshift(client, &snapshot, &args).await,
ApiCommand::FnLock { device, set } => {
let route = select_device(&snapshot, &device)?;
let state = match set {
Some(set) => {
let requested = matches!(set, Switch::On);
let observed =
agent::call(client.set_fn_lock(context::current(), route, requested))
.await??;
if observed.fn_lock != requested {
return Err(ApiError::new(
"readback_mismatch",
"device Fn lock differs from the requested state after the write",
));
}
observed
}
None => agent::call(client.read_fn_lock(context::current(), route)).await??,
};
Ok(json!({
"device": device,
"persistence": "not_saved",
"fn_lock": state.fn_lock,
"default_fn_lock": state.default_fn_lock,
}))
}
}
}
async fn dpi(
client: &AgentClient,
snapshot: &AgentSnapshot,
device: &str,
set: Option<u16>,
) -> Result<Value, ApiError> {
let route = select_device(snapshot, device)?;
let mut info = agent::call(client.read_dpi(context::current(), route.clone())).await??;
if let Some(value) = set {
let requested = Dpi::from(value);
if !info.capabilities.contains(requested) {
return Err(ApiError::new(
"invalid_value",
"DPI is not in the device-reported supported values",
));
}
agent::call(client.set_dpi(context::current(), route.clone(), requested)).await??;
info = agent::call(client.read_dpi(context::current(), route)).await??;
if info.current != requested {
return Err(ApiError::new(
"readback_mismatch",
"device DPI differs from the requested value after the write",
));
}
}
Ok(json!({
"device": device,
"persistence": "not_saved",
"current": info.current,
"supported": info.capabilities.values(),
}))
}
async fn smartshift(
client: &AgentClient,
snapshot: &AgentSnapshot,
args: &SmartshiftArgs,
) -> Result<Value, ApiError> {
let route = select_device(snapshot, &args.device)?;
let mut state =
agent::call(client.read_smartshift(context::current(), route.clone())).await??;
if args.mode.is_some() || args.auto_disengage.is_some() {
if let Some(mode) = args.mode {
state.mode = mode.into();
}
if let Some(auto_disengage) = args.auto_disengage {
state.auto_disengage = auto_disengage.into();
}
agent::call(client.set_smartshift(context::current(), route.clone(), state)).await??;
let observed = agent::call(client.read_smartshift(context::current(), route)).await??;
if observed != state {
return Err(ApiError::new(
"readback_mismatch",
"device SmartShift differs from the requested state after the write",
));
}
state = observed;
}
Ok(json!({
"device": args.device,
"persistence": "not_saved",
"mode": match state.mode {
SmartShiftMode::Free => "free",
SmartShiftMode::Ratchet => "ratchet",
},
"auto_disengage": u8::from(state.auto_disengage),
"tunable_torque": state.tunable_torque.map(openlogi_core::hid::TunableTorque::into_inner),
}))
}
impl From<WriteError> for ApiError {
fn from(error: WriteError) -> Self {
let code = match &error {
WriteError::FeatureUnsupported { .. } | WriteError::LightUnsupported { .. } => {
"unsupported_feature"
}
WriteError::DeviceNotFound => "device_not_found",
WriteError::DeviceUnreachable { .. } => "device_offline",
WriteError::AgentUnavailable => "agent_unavailable",
WriteError::RequestTimedOut { .. } => "timeout",
WriteError::AmbiguousRawDevice => "ambiguous_device",
_ => "device_error",
};
Self::new(code, error.to_string())
}
}
#[cfg(test)]
mod tests;