use hidapi::{HidApi, HidDevice};
const PINSCAPE_VID: u16 = 0x1209;
const PINSCAPE_PID: u16 = 0xEAEA;
const OPAQUE_BOARDS: &[(u16, u16, &str)] = &[
(0x2E8A, 0x106F, "DudesCab"),
(0x0E8F, 0x9207, "PinOne"),
(0x39F5, 0x9208, "PinOne Ultra"),
];
const CMD_EXTENDED: u8 = 65;
const CMD_QUERY_CONFIG_VAR: u8 = 9;
const VAR_ACCELEROMETER: u8 = 4;
const VAR_PLUNGER_TYPE: u8 = 5;
const PICO_REPORT_ID_REQUEST: u8 = 0x04;
const PICO_REQ_QUERY_ID: u8 = 0x01;
const PICO_REQ_QUERY_STATUS: u8 = 0x02;
const PICO_REPORT_ID_ID: u8 = 0x01;
const PICO_REPORT_ID_STATUS: u8 = 0x02;
const REPLY_CONFIG_VAR: [u8; 2] = [0x00, 0x98];
const REPLY_TIMEOUT_MS: i32 = 50;
const MAX_REPORTS: usize = 40;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Board {
Kl25z,
Pico,
Opaque(&'static str),
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PinscapeConfig {
pub board: Board,
pub accel_range_g: Option<f32>,
pub accel_orientation: Option<u8>,
pub accel_autocenter: Option<u8>,
pub plunger_type: Option<u8>,
pub plunger_enabled: Option<bool>,
pub plunger_calibrated: Option<bool>,
}
impl PinscapeConfig {
pub fn orientation_label(&self) -> &'static str {
match self.accel_orientation {
Some(0) => "USB ports at front",
Some(1) => "USB ports at left",
Some(2) => "USB ports at right",
Some(3) => "USB ports at rear",
_ => "unknown orientation",
}
}
pub fn orientation_key(&self) -> Option<&'static str> {
match self.accel_orientation {
Some(0) => Some("tilt_orientation_front"),
Some(1) => Some("tilt_orientation_left"),
Some(2) => Some("tilt_orientation_right"),
Some(3) => Some("tilt_orientation_rear"),
_ => None,
}
}
pub fn has_plunger(&self) -> Option<bool> {
match (self.plunger_type, self.plunger_enabled) {
(Some(t), _) => Some(t != 0),
(None, Some(enabled)) => Some(enabled),
_ => None,
}
}
}
pub fn read() -> Option<PinscapeConfig> {
let api = HidApi::new()
.inspect_err(|e| log::debug!("Pinscape config: HidApi init failed: {e}"))
.ok()?;
let board = api
.device_list()
.find(|d| d.vendor_id() == PINSCAPE_VID && d.product_id() == PINSCAPE_PID)
.map(|d| {
let product = d.product_string().unwrap_or_default().to_lowercase();
if product.contains("pico") {
Board::Pico
} else {
Board::Kl25z
}
})
.or_else(|| {
api.device_list().find_map(|d| {
OPAQUE_BOARDS
.iter()
.find(|(vid, pid, _)| d.vendor_id() == *vid && d.product_id() == *pid)
.map(|(_, _, name)| Board::Opaque(name))
})
})?;
if let Board::Opaque(name) = board {
log::info!("{name} detected — its firmware publishes no input settings");
return Some(PinscapeConfig {
board,
accel_range_g: None,
accel_orientation: None,
accel_autocenter: None,
plunger_type: None,
plunger_enabled: None,
plunger_calibrated: None,
});
}
let device = api
.open(PINSCAPE_VID, PINSCAPE_PID)
.inspect_err(|e| log::debug!("Pinscape config: no board to query ({e})"))
.ok()?;
match board {
Board::Pico => read_pico(&device),
Board::Kl25z => read_kl25z(&device),
Board::Opaque(_) => None,
}
}
fn read_kl25z(device: &HidDevice) -> Option<PinscapeConfig> {
let accel = query(device, VAR_ACCELEROMETER, 0)?;
let accel_range_g = match accel[4] {
1 => 2.0,
2 => 4.0,
3 => 8.0,
_ => 1.0,
};
let plunger_type = query(device, VAR_PLUNGER_TYPE, 0).map(|v| v[3]);
let config = PinscapeConfig {
board: Board::Kl25z,
accel_range_g: Some(accel_range_g),
accel_orientation: Some(accel[3]),
accel_autocenter: Some(accel[5]),
plunger_type,
plunger_enabled: None,
plunger_calibrated: None,
};
log::info!(
"Pinscape KL25Z: accelerometer ±{accel_range_g} g, {}, plunger type {}",
config.orientation_label(),
plunger_type.map_or("unknown".to_string(), |t| t.to_string())
);
Some(config)
}
fn read_pico(device: &HidDevice) -> Option<PinscapeConfig> {
let mut request = [0u8; 64];
request[0] = PICO_REPORT_ID_REQUEST;
request[1] = PICO_REQ_QUERY_STATUS;
device
.write(&request)
.inspect_err(|e| log::debug!("Pinscape Pico: status query failed: {e}"))
.ok()?;
let mut buffer = [0u8; 64];
for _ in 0..MAX_REPORTS {
let read = device.read_timeout(&mut buffer, REPLY_TIMEOUT_MS).ok()?;
if read > 1 && buffer[0] == PICO_REPORT_ID_STATUS {
let flags = buffer[1];
let plunger_type = query_pico_id(device);
let config = PinscapeConfig {
board: Board::Pico,
accel_range_g: None,
accel_orientation: None,
accel_autocenter: None,
plunger_type,
plunger_enabled: Some(flags & 0x01 != 0),
plunger_calibrated: Some(flags & 0x02 != 0),
};
log::info!(
"Pinscape Pico: plunger {} ({})",
if config.plunger_enabled == Some(true) {
"enabled"
} else {
"disabled"
},
if config.plunger_calibrated == Some(true) {
"calibrated"
} else {
"not calibrated"
}
);
return Some(config);
}
}
log::debug!("Pinscape Pico: no status reply");
None
}
fn query_pico_id(device: &HidDevice) -> Option<u8> {
let mut request = [0u8; 64];
request[0] = PICO_REPORT_ID_REQUEST;
request[1] = PICO_REQ_QUERY_ID;
device.write(&request).ok()?;
const PLUNGER_TYPE_OFFSET: usize = 46;
let mut buffer = [0u8; 64];
for _ in 0..MAX_REPORTS {
let read = device.read_timeout(&mut buffer, REPLY_TIMEOUT_MS).ok()?;
if read > PLUNGER_TYPE_OFFSET + 1 && buffer[0] == PICO_REPORT_ID_ID {
return Some(buffer[PLUNGER_TYPE_OFFSET]);
}
}
None
}
fn query(device: &HidDevice, variable: u8, index: u8) -> Option<[u8; 8]> {
let request = [
0x00,
CMD_EXTENDED,
CMD_QUERY_CONFIG_VAR,
variable,
index,
0,
0,
0,
0,
];
device
.write(&request)
.inspect_err(|e| log::debug!("Pinscape config: query {variable} failed: {e}"))
.ok()?;
let mut buffer = [0u8; 64];
for _ in 0..MAX_REPORTS {
let read = device.read_timeout(&mut buffer, REPLY_TIMEOUT_MS).ok()?;
if read < 8 {
continue;
}
if buffer[0..2] == REPLY_CONFIG_VAR && buffer[2] == variable {
let mut value = [0u8; 8];
value.copy_from_slice(&buffer[0..8]);
return Some(value);
}
}
log::debug!("Pinscape config: no reply for variable {variable}");
None
}
pub fn spawn_read() -> crossbeam_channel::Receiver<Option<PinscapeConfig>> {
let (tx, rx) = crossbeam_channel::bounded(1);
std::thread::Builder::new()
.name("pinready-pinscape-cfg".into())
.spawn(move || {
let _ = tx.send(read());
})
.expect("spawn pinscape config thread");
rx
}