use crate::outputs_pinone::{self as pinone, PortProblem};
use crossbeam_channel::{Receiver, Sender};
use hidapi::{HidApi, HidDevice};
use std::ffi::{CStr, CString};
use std::thread;
use std::time::Duration;
const LEDWIZ_VID: u16 = 0xFAFA;
const LEDWIZ_PID_BASE: u16 = 0x00EF;
const PINSCAPE_PRIVATE_VID: u16 = 0x1209;
const PINSCAPE_PRIVATE_PID: u16 = 0xEAEA;
const ULTIMARC_VID: u16 = 0xD209;
const PACDRIVE_PID: u16 = 0x1500;
const PACLED_PID_MIN: u16 = 0x1401;
const PACLED_PID_MAX: u16 = 0x1404;
const ULTIMATEIO_PID_MIN: u16 = 0x0410;
const ULTIMATEIO_PID_MAX: u16 = 0x0411;
const DUDESCAB_VID: u16 = 0x2E8A;
const DUDESCAB_PID: u16 = 0x106F;
const HID_USAGE_PAGE_GENERIC: u16 = 0x01;
const HID_USAGE_JOYSTICK: u16 = 0x04;
const PSPICO_USAGE_PAGE: u16 = 0x06;
const PSPICO_USAGE: u16 = 0x00;
const CMD_SET_BRIGHTNESS_BASE: u8 = 200;
const CMD_EXTENDED: u8 = 65;
const CMD_EXT_ALL_OFF: u8 = 5;
const OUTPUTS_PER_GROUP: u16 = 7;
const PINSCAPE_KL25Z_DEFAULT_OUTPUTS: u16 = 22;
const PINSCAPE_PICO_DEFAULT_OUTPUTS: u16 = 32;
const DUDESCAB_DEFAULT_OUTPUTS: u16 = 32;
const LEDWIZ_DEFAULT_OUTPUTS: u16 = 32;
const PACDRIVE_DEFAULT_OUTPUTS: u16 = 16;
const PACLED_DEFAULT_OUTPUTS: u16 = 64;
const ULTIMATEIO_DEFAULT_OUTPUTS: u16 = 96;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BoardLocation {
Hid(CString),
Serial(String),
}
pub type BoardProblem = PortProblem;
#[derive(Debug, Clone)]
pub struct DetectedBoard {
pub kind: BoardKind,
pub vid: u16,
pub pid: u16,
pub location: BoardLocation,
pub product: String,
pub num_outputs: u16,
pub firmware: Option<String>,
pub problem: Option<BoardProblem>,
}
impl DetectedBoard {
pub fn path_str(&self) -> String {
match &self.location {
BoardLocation::Hid(p) => p.to_string_lossy().into_owned(),
BoardLocation::Serial(port) => port.clone(),
}
}
pub fn configtool_controller(&self) -> &'static str {
match self.kind {
BoardKind::PinscapeKL25Z => {
if self.vid == LEDWIZ_VID {
"FRDM-KL25Z"
} else {
"Pinscape"
}
}
BoardKind::PinscapePico => "PinscapePico",
BoardKind::LedWiz => "Ledwiz",
BoardKind::DudesCab => "DudesCab",
BoardKind::PacDrive => "PacDrive",
BoardKind::PacLed => "PacLed",
BoardKind::UltimateIO => "Ultimate/IO",
BoardKind::PinOne | BoardKind::PinOneUltra => "PinOne",
BoardKind::Simulator => "Simulator",
}
}
pub fn firmware_mode(&self) -> String {
match self.kind {
BoardKind::PinscapeKL25Z => {
if self.vid == LEDWIZ_VID {
"Pinscape v1 (LedWiz emul mode)".to_string()
} else {
"Pinscape v2+ (native protocol)".to_string()
}
}
_ => self.firmware.clone().unwrap_or_default(),
}
}
pub fn expected_config_filename(&self) -> Option<String> {
let suffix: u16 = match self.kind {
BoardKind::PinscapeKL25Z => {
if self.vid == LEDWIZ_VID {
(self.pid & 0x0F) + 1
} else {
51
}
}
BoardKind::PinscapePico => 120,
BoardKind::LedWiz => (self.pid & 0x0F) + 1,
BoardKind::DudesCab => 90,
BoardKind::PacDrive => 19,
BoardKind::PacLed => 20,
BoardKind::UltimateIO => 27,
BoardKind::PinOne | BoardKind::PinOneUltra => 11,
BoardKind::Simulator => return None,
};
Some(format!("directoutputconfig{suffix}.ini"))
}
}
pub fn detect_boards() -> Result<Vec<DetectedBoard>, String> {
log::info!("Scanning USB for supported output boards...");
let api = HidApi::new().map_err(|e| {
log::error!("HidApi::new failed: {e}");
format!("HidApi init failed: {e}")
})?;
let mut out = Vec::new();
for info in api.device_list() {
let vid = info.vendor_id();
let pid = info.product_id();
let usage_page = info.usage_page();
let usage = info.usage();
let product = info.product_string().unwrap_or("").to_string();
if vid == pinone::ULTRA_VID || (vid == pinone::LEGACY_VID && pid == pinone::LEGACY_PID) {
log::info!(
" PinOne HID candidate: VID {vid:04X} PID {pid:04X} usage={usage_page:#06X}/{usage:#04X} path={:?} product={product:?}",
info.path()
);
}
let Some(kind) = classify_hid(vid, pid, usage_page, usage, &product) else {
continue;
};
if !is_correct_interface(kind, usage_page, usage) {
continue;
}
log::info!(
" detected {} : VID {:04X} PID {:04X} usage={}/{} path={:?} product={:?}",
kind.label(),
vid,
pid,
usage_page,
usage,
info.path(),
product
);
out.push(DetectedBoard {
kind,
vid,
pid,
location: BoardLocation::Hid(info.path().to_owned()),
product,
num_outputs: default_outputs(kind),
firmware: None,
problem: None,
});
}
out.dedup_by(|a, b| a.kind == b.kind && a.location == b.location);
for board in out.iter_mut().filter(|b| b.kind == BoardKind::PinOneUltra) {
let BoardLocation::Hid(path) = &board.location else {
continue;
};
match pinone::ultra_query_version(&api, path) {
Ok(v) => board.firmware = v,
Err(e) => {
let low = e.to_lowercase();
if low.contains("permission") || low.contains("access") {
board.problem = Some(PortProblem::PermissionDenied);
}
log::warn!("PinOne Ultra: version query failed: {e}");
}
}
log::info!(
"PinOne Ultra candidate {:?}: firmware={:?} problem={:?}",
path,
board.firmware,
board.problem
);
}
for f in pinone::detect_serial() {
out.push(DetectedBoard {
kind: BoardKind::PinOne,
vid: f.vid,
pid: f.pid,
location: BoardLocation::Serial(f.port),
product: f.product,
num_outputs: pinone::PINONE_OUTPUTS,
firmware: f.firmware,
problem: f.problem,
});
}
log::info!("Scan complete: {} supported board(s) found", out.len());
Ok(out)
}
fn classify_hid(
vid: u16,
pid: u16,
usage_page: u16,
usage: u16,
product: &str,
) -> Option<BoardKind> {
if usage_page == PSPICO_USAGE_PAGE && usage == PSPICO_USAGE && product == "PinscapePico" {
return Some(BoardKind::PinscapePico);
}
if vid == PINSCAPE_PRIVATE_VID && pid == PINSCAPE_PRIVATE_PID {
return Some(BoardKind::PinscapeKL25Z);
}
if vid == LEDWIZ_VID && pid > LEDWIZ_PID_BASE && pid <= LEDWIZ_PID_BASE + 16 {
let p = product.to_ascii_lowercase();
return if p.contains("pinscape") || p.contains("mjrnet") {
Some(BoardKind::PinscapeKL25Z)
} else {
Some(BoardKind::LedWiz)
};
}
if pinone::is_ultra(vid, pid, product) {
return Some(BoardKind::PinOneUltra);
}
if vid == DUDESCAB_VID && pid == DUDESCAB_PID {
let is_mx = product.contains("MX");
if !is_mx {
return Some(BoardKind::DudesCab);
}
}
if vid == ULTIMARC_VID {
if pid == PACDRIVE_PID {
return Some(BoardKind::PacDrive);
}
if (PACLED_PID_MIN..=PACLED_PID_MAX).contains(&pid) {
return Some(BoardKind::PacLed);
}
if (ULTIMATEIO_PID_MIN..=ULTIMATEIO_PID_MAX).contains(&pid) {
return Some(BoardKind::UltimateIO);
}
}
None
}
fn is_correct_interface(kind: BoardKind, usage_page: u16, usage: u16) -> bool {
match kind {
BoardKind::PinscapeKL25Z | BoardKind::LedWiz => {
usage_page == HID_USAGE_PAGE_GENERIC && usage == HID_USAGE_JOYSTICK
}
BoardKind::PinscapePico => usage_page == PSPICO_USAGE_PAGE && usage == PSPICO_USAGE,
BoardKind::PinOneUltra => {
usage_page == pinone::ULTRA_USAGE_PAGE && usage == pinone::ULTRA_USAGE
}
_ => true,
}
}
fn default_outputs(kind: BoardKind) -> u16 {
match kind {
BoardKind::Simulator | BoardKind::PinscapeKL25Z => PINSCAPE_KL25Z_DEFAULT_OUTPUTS,
BoardKind::PinscapePico => PINSCAPE_PICO_DEFAULT_OUTPUTS,
BoardKind::LedWiz => LEDWIZ_DEFAULT_OUTPUTS,
BoardKind::DudesCab => DUDESCAB_DEFAULT_OUTPUTS,
BoardKind::PacDrive => PACDRIVE_DEFAULT_OUTPUTS,
BoardKind::PacLed => PACLED_DEFAULT_OUTPUTS,
BoardKind::UltimateIO => ULTIMATEIO_DEFAULT_OUTPUTS,
BoardKind::PinOne | BoardKind::PinOneUltra => pinone::PINONE_OUTPUTS,
}
}
#[cfg(target_os = "linux")]
pub fn apply_udev_rules(rules: &str) -> Result<(), String> {
let tmp = std::env::temp_dir().join("pinready-99-pinscape.rules");
std::fs::write(&tmp, rules).map_err(|e| format!("write temp file: {e}"))?;
log::info!(
"Installing udev rules via pkexec (temp source: {})",
tmp.display()
);
let tmp_path = tmp.to_string_lossy().replace('\'', "'\\''");
let script = format!(
"install -m 0644 '{tmp_path}' /etc/udev/rules.d/99-pinscape.rules \
&& udevadm control --reload-rules \
&& udevadm trigger --action=change \
&& udevadm settle --timeout=5"
);
let status = std::process::Command::new("pkexec")
.arg("sh")
.arg("-c")
.arg(&script)
.status()
.map_err(|e| format!("pkexec invocation failed: {e}. Is polkit installed?"))?;
let _ = std::fs::remove_file(&tmp);
match status.code() {
Some(0) => {
log::info!("udev rules installed and reloaded successfully");
Ok(())
}
Some(126) => {
log::warn!("udev install cancelled by user (pkexec exit 126)");
Err("Authorisation cancelled".to_string())
}
Some(127) => {
log::error!("pkexec not found on PATH — polkit missing?");
Err(
"pkexec not found — install polkit (e.g. `sudo apt install policykit-1`)"
.to_string(),
)
}
Some(code) => {
log::error!("pkexec exited with code {code}");
Err(format!("pkexec exited with code {code}"))
}
None => {
log::error!("pkexec terminated by signal");
Err("pkexec terminated by signal".to_string())
}
}
}
#[cfg(target_os = "linux")]
pub fn spawn_udev_apply(rules: String) -> Receiver<Result<(), String>> {
let (tx, rx) = crossbeam_channel::bounded(1);
thread::spawn(move || {
let _ = tx.send(apply_udev_rules(&rules));
});
rx
}
pub fn open_board(
kind: BoardKind,
location: &BoardLocation,
num_outputs: u16,
) -> Result<Box<dyn OutputBoard>, String> {
let path = match (kind, location) {
(BoardKind::PinOne, BoardLocation::Serial(port)) => {
return open_pinone(PinOneLink::Serial(pinone::PinOneSerial::open(
port,
num_outputs,
)?));
}
(_, BoardLocation::Serial(port)) => {
return Err(format!(
"{} is not reached over a serial port ({port})",
kind.label()
));
}
(_, BoardLocation::Hid(path)) => path.as_c_str(),
};
match kind {
BoardKind::PinOneUltra => open_pinone(PinOneLink::Hid(pinone::PinOneUltraHid::open(
path,
num_outputs,
)?)),
BoardKind::PinscapeKL25Z => open_pinscape_kl25z(path, num_outputs),
BoardKind::LedWiz => open_ledwiz(path, num_outputs),
BoardKind::PinscapePico => open_pinscape_pico(path, num_outputs),
BoardKind::DudesCab => open_dudescab(path, num_outputs),
BoardKind::PacDrive => open_pacdrive(path, num_outputs),
BoardKind::PacLed | BoardKind::UltimateIO => open_pac_indexed(kind, path, num_outputs),
other => Err(format!(
"{} HID write driver not yet implemented",
other.label()
)),
}
}
pub fn open_pinscape_kl25z(path: &CStr, num_outputs: u16) -> Result<Box<dyn OutputBoard>, String> {
log::info!(
"Opening Pinscape device: {} ({} outputs)",
path.to_string_lossy(),
num_outputs
);
let api = HidApi::new().map_err(|e| {
log::error!("HidApi::new on open failed: {e}");
format!("HidApi init failed: {e}")
})?;
let dev = api.open_path(path).map_err(|e| {
log::error!("open_path({:?}) failed: {e}", path);
format!("Open HID device: {e}")
})?;
log::info!("Pinscape device opened successfully");
Ok(Box::new(PinscapeKL25ZReal {
dev,
shadow: vec![0u8; num_outputs as usize],
num_outputs,
}))
}
pub fn open_ledwiz(path: &CStr, num_outputs: u16) -> Result<Box<dyn OutputBoard>, String> {
let n = num_outputs.min(32);
log::info!(
"Opening LedWiz device: {} ({} outputs)",
path.to_string_lossy(),
n
);
let api = HidApi::new().map_err(|e| format!("HidApi init failed: {e}"))?;
let dev = api
.open_path(path)
.map_err(|e| format!("Open HID device: {e}"))?;
log::info!("LedWiz device opened successfully");
Ok(Box::new(LedWizReal {
dev,
shadow: vec![0u8; n as usize],
num_outputs: n,
}))
}
pub fn open_pinscape_pico(path: &CStr, num_outputs: u16) -> Result<Box<dyn OutputBoard>, String> {
log::info!(
"Opening Pinscape Pico device: {} ({} outputs)",
path.to_string_lossy(),
num_outputs
);
let api = HidApi::new().map_err(|e| format!("HidApi init failed: {e}"))?;
let dev = api
.open_path(path)
.map_err(|e| format!("Open HID device: {e}"))?;
log::info!("Pinscape Pico device opened successfully");
Ok(Box::new(PinscapePicoReal { dev, num_outputs }))
}
pub fn open_dudescab(path: &CStr, num_outputs: u16) -> Result<Box<dyn OutputBoard>, String> {
log::info!(
"Opening DudesCab device: {} ({} outputs, assuming firmware 1.9.0+)",
path.to_string_lossy(),
num_outputs
);
let api = HidApi::new().map_err(|e| format!("HidApi init failed: {e}"))?;
let dev = api
.open_path(path)
.map_err(|e| format!("Open HID device: {e}"))?;
log::info!("DudesCab device opened successfully");
Ok(Box::new(DudesCabReal { dev, num_outputs }))
}
pub fn open_pacdrive(path: &CStr, num_outputs: u16) -> Result<Box<dyn OutputBoard>, String> {
let n = num_outputs.min(16);
log::info!(
"Opening PacDrive device: {} ({n} outputs)",
path.to_string_lossy()
);
let api = HidApi::new().map_err(|e| format!("HidApi init failed: {e}"))?;
let dev = api
.open_path(path)
.map_err(|e| format!("Open HID device: {e}"))?;
log::info!("PacDrive device opened successfully");
Ok(Box::new(PacDriveReal {
dev,
shadow: 0u16,
num_outputs: n,
}))
}
pub fn open_pac_indexed(
kind: BoardKind,
path: &CStr,
num_outputs: u16,
) -> Result<Box<dyn OutputBoard>, String> {
let cap = match kind {
BoardKind::PacLed => 64,
BoardKind::UltimateIO => 96,
_ => return Err("open_pac_indexed only supports PacLed / UltimateIO".to_string()),
};
let n = num_outputs.min(cap);
log::info!(
"Opening {} device: {} ({n} outputs)",
kind.label(),
path.to_string_lossy()
);
let api = HidApi::new().map_err(|e| format!("HidApi init failed: {e}"))?;
let dev = api
.open_path(path)
.map_err(|e| format!("Open HID device: {e}"))?;
log::info!("{} device opened successfully", kind.label());
Ok(Box::new(PacIndexedReal {
dev,
kind,
num_outputs: n,
}))
}
pub const PULSE_ON_MS: u64 = 50;
pub const PULSE_OFF_MS: u64 = 500;
pub const MAX_LOOP_PULSES: u32 = 30;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BoardKind {
Simulator,
PinscapeKL25Z,
PinscapePico,
LedWiz,
DudesCab,
PacDrive,
PacLed,
UltimateIO,
PinOne,
PinOneUltra,
}
impl BoardKind {
pub fn label(self) -> &'static str {
match self {
Self::Simulator => "Simulator",
Self::PinscapeKL25Z => "Pinscape KL25Z",
Self::PinscapePico => "Pinscape Pico",
Self::LedWiz => "LedWiz",
Self::DudesCab => "DudesCab",
Self::PacDrive => "PacDrive",
Self::PacLed => "PacLed",
Self::UltimateIO => "Ultimate I/O",
Self::PinOne => "PinOne",
Self::PinOneUltra => "PinOne Ultra",
}
}
pub fn has_driver(self) -> bool {
matches!(
self,
Self::Simulator
| Self::PinscapeKL25Z
| Self::LedWiz
| Self::PinscapePico
| Self::DudesCab
| Self::PacDrive
| Self::PacLed
| Self::UltimateIO
| Self::PinOne
| Self::PinOneUltra
)
}
}
pub trait OutputBoard: Send {
fn kind(&self) -> BoardKind;
fn num_outputs(&self) -> u16;
fn set_output(&mut self, n: u16, intensity: u8) -> Result<(), String>;
fn all_off(&mut self) -> Result<(), String>;
}
pub struct Simulator {
outputs: u16,
}
impl Simulator {
pub fn new(outputs: u16) -> Self {
Self { outputs }
}
}
impl OutputBoard for Simulator {
fn kind(&self) -> BoardKind {
BoardKind::Simulator
}
fn num_outputs(&self) -> u16 {
self.outputs
}
fn set_output(&mut self, n: u16, intensity: u8) -> Result<(), String> {
if intensity == 0 {
log::debug!("[sim] output #{n} off");
} else {
log::info!("[sim] output #{n} = {intensity}");
}
Ok(())
}
fn all_off(&mut self) -> Result<(), String> {
log::info!("[sim] all off");
Ok(())
}
}
struct PinscapeKL25ZReal {
dev: HidDevice,
shadow: Vec<u8>,
num_outputs: u16,
}
impl PinscapeKL25ZReal {
fn send_group(&mut self, group: u16) -> Result<(), String> {
let base_idx = (group * OUTPUTS_PER_GROUP) as usize;
let mut report = [0u8; 9];
report[0] = 0x00;
report[1] = CMD_SET_BRIGHTNESS_BASE + (group as u8);
for i in 0..OUTPUTS_PER_GROUP as usize {
report[2 + i] = *self.shadow.get(base_idx + i).unwrap_or(&0);
}
self.dev
.write(&report)
.map(|_| ())
.map_err(|e| format!("HID write failed: {e}"))
}
}
impl OutputBoard for PinscapeKL25ZReal {
fn kind(&self) -> BoardKind {
BoardKind::PinscapeKL25Z
}
fn num_outputs(&self) -> u16 {
self.num_outputs
}
fn set_output(&mut self, n: u16, intensity: u8) -> Result<(), String> {
if n == 0 || n > self.num_outputs {
return Err(format!(
"output {n} out of range (1..={})",
self.num_outputs
));
}
self.shadow[(n - 1) as usize] = intensity;
let group = (n - 1) / OUTPUTS_PER_GROUP;
self.send_group(group)
}
fn all_off(&mut self) -> Result<(), String> {
for v in self.shadow.iter_mut() {
*v = 0;
}
let report = [0x00, CMD_EXTENDED, CMD_EXT_ALL_OFF, 0, 0, 0, 0, 0, 0];
self.dev
.write(&report)
.map(|_| ())
.map_err(|e| format!("HID write failed: {e}"))
}
}
struct LedWizReal {
dev: HidDevice,
shadow: Vec<u8>,
num_outputs: u16,
}
impl LedWizReal {
fn send_sba(&mut self) -> Result<(), String> {
let mut sba = [0u8; 9];
sba[1] = 0x40; sba[6] = 0x02; for i in 0..self.shadow.len().min(32) {
if self.shadow[i] > 127 {
sba[2 + i / 8] |= 1 << (i % 8);
}
}
self.dev
.write(&sba)
.map(|_| ())
.map_err(|e| format!("HID write (SBA): {e}"))
}
fn send_pba(&mut self) -> Result<(), String> {
for group in 0..4 {
let mut pba = [0u8; 9];
for i in 0..8 {
let idx = group * 8 + i;
if idx < self.shadow.len() {
pba[1 + i] = ((self.shadow[idx] as u32 * 48) / 255) as u8;
}
}
self.dev
.write(&pba)
.map(|_| ())
.map_err(|e| format!("HID write (PBA): {e}"))?;
}
Ok(())
}
}
impl OutputBoard for LedWizReal {
fn kind(&self) -> BoardKind {
BoardKind::LedWiz
}
fn num_outputs(&self) -> u16 {
self.num_outputs
}
fn set_output(&mut self, n: u16, intensity: u8) -> Result<(), String> {
if n == 0 || n > self.num_outputs {
return Err(format!(
"output {n} out of range (1..={})",
self.num_outputs
));
}
self.shadow[(n - 1) as usize] = intensity;
self.send_sba()?;
self.send_pba()
}
fn all_off(&mut self) -> Result<(), String> {
for v in self.shadow.iter_mut() {
*v = 0;
}
let off = [0u8, 0x40, 0, 0, 0, 0, 0x02, 0, 0];
self.dev
.write(&off)
.map(|_| ())
.map_err(|e| format!("HID write (all_off): {e}"))
}
}
const PSPICO_REPORT_ID: u8 = 4;
const PSPICO_REPORT_LEN: usize = 64;
const PSPICO_CMD_ALL_OFF: u8 = 0x20;
const PSPICO_CMD_SET_OUTPUTS: u8 = 0x22;
struct PinscapePicoReal {
dev: HidDevice,
num_outputs: u16,
}
impl OutputBoard for PinscapePicoReal {
fn kind(&self) -> BoardKind {
BoardKind::PinscapePico
}
fn num_outputs(&self) -> u16 {
self.num_outputs
}
fn set_output(&mut self, n: u16, intensity: u8) -> Result<(), String> {
if n == 0 || n > self.num_outputs {
return Err(format!(
"output {n} out of range (1..={})",
self.num_outputs
));
}
let mut buf = [0u8; PSPICO_REPORT_LEN];
buf[0] = PSPICO_REPORT_ID;
buf[1] = PSPICO_CMD_SET_OUTPUTS;
buf[2] = 1; buf[3] = n as u8; buf[4] = intensity;
self.dev
.write(&buf)
.map(|_| ())
.map_err(|e| format!("HID write (0x22 SET OUTPUT PORTS): {e}"))
}
fn all_off(&mut self) -> Result<(), String> {
let mut buf = [0u8; PSPICO_REPORT_LEN];
buf[0] = PSPICO_REPORT_ID;
buf[1] = PSPICO_CMD_ALL_OFF;
self.dev
.write(&buf)
.map(|_| ())
.map_err(|e| format!("HID write (0x20 ALL PORTS OFF): {e}"))
}
}
const DUDESCAB_RID_OUTPUTS: u8 = 3;
const DUDESCAB_CMD_ALLOFF: u8 = 101;
const DUDESCAB_CMD_OUTPUTS: u8 = 102;
const DUDESCAB_HEADER_SIZE: usize = 5;
const DUDESCAB_OUTPUTS_PER_EXT: u16 = 16;
struct DudesCabReal {
dev: HidDevice,
num_outputs: u16,
}
impl OutputBoard for DudesCabReal {
fn kind(&self) -> BoardKind {
BoardKind::DudesCab
}
fn num_outputs(&self) -> u16 {
self.num_outputs
}
fn set_output(&mut self, n: u16, intensity: u8) -> Result<(), String> {
if n == 0 || n > self.num_outputs {
return Err(format!(
"output {n} out of range (1..={})",
self.num_outputs
));
}
let idx = n - 1;
let ext = (idx / DUDESCAB_OUTPUTS_PER_EXT) as u8;
let bit = idx % DUDESCAB_OUTPUTS_PER_EXT;
let ext_mask = 1u8 << ext;
let out_mask = 1u16 << bit;
let payload = [
ext_mask,
(out_mask & 0xFF) as u8,
((out_mask >> 8) & 0xFF) as u8,
intensity,
];
let mut buf = [0u8; DUDESCAB_HEADER_SIZE + 4];
buf[0] = DUDESCAB_RID_OUTPUTS;
buf[1] = DUDESCAB_CMD_OUTPUTS;
buf[2] = 0; buf[3] = 1; buf[4] = payload.len() as u8;
buf[DUDESCAB_HEADER_SIZE..].copy_from_slice(&payload);
self.dev
.write(&buf)
.map(|_| ())
.map_err(|e| format!("HID write (DudesCab RT_PWM_OUTPUTS): {e}"))
}
fn all_off(&mut self) -> Result<(), String> {
let buf = [
DUDESCAB_RID_OUTPUTS,
DUDESCAB_CMD_ALLOFF,
0, 1, 0, ];
self.dev
.write(&buf)
.map(|_| ())
.map_err(|e| format!("HID write (DudesCab RT_PWM_ALLOFF): {e}"))
}
}
struct PacDriveReal {
dev: HidDevice,
shadow: u16,
num_outputs: u16,
}
impl PacDriveReal {
fn send_state(&mut self) -> Result<(), String> {
let buf = [
0x00u8, 0,
0,
((self.shadow >> 8) & 0xFF) as u8,
(self.shadow & 0xFF) as u8,
];
self.dev
.write(&buf)
.map(|_| ())
.map_err(|e| format!("HID write (PacDrive state): {e}"))
}
}
impl OutputBoard for PacDriveReal {
fn kind(&self) -> BoardKind {
BoardKind::PacDrive
}
fn num_outputs(&self) -> u16 {
self.num_outputs
}
fn set_output(&mut self, n: u16, intensity: u8) -> Result<(), String> {
if n == 0 || n > self.num_outputs {
return Err(format!(
"output {n} out of range (1..={})",
self.num_outputs
));
}
let bit = 1u16 << (n - 1);
if intensity > 127 {
self.shadow |= bit;
} else {
self.shadow &= !bit;
}
self.send_state()
}
fn all_off(&mut self) -> Result<(), String> {
self.shadow = 0;
self.send_state()
}
}
struct PacIndexedReal {
dev: HidDevice,
kind: BoardKind,
num_outputs: u16,
}
impl OutputBoard for PacIndexedReal {
fn kind(&self) -> BoardKind {
self.kind
}
fn num_outputs(&self) -> u16 {
self.num_outputs
}
fn set_output(&mut self, n: u16, intensity: u8) -> Result<(), String> {
if n == 0 || n > self.num_outputs {
return Err(format!(
"output {n} out of range (1..={})",
self.num_outputs
));
}
let buf = [0x00u8, (n - 1) as u8, intensity];
self.dev
.write(&buf)
.map(|_| ())
.map_err(|e| format!("HID write (PacLed/UltimateIO set): {e}"))
}
fn all_off(&mut self) -> Result<(), String> {
for i in 0..self.num_outputs {
let buf = [0x00u8, i as u8, 0];
self.dev
.write(&buf)
.map_err(|e| format!("HID write (PacLed/UltimateIO all_off {i}): {e}"))?;
}
Ok(())
}
}
enum PinOneLink {
Serial(pinone::PinOneSerial),
Hid(pinone::PinOneUltraHid),
}
struct PinOneReal {
link: PinOneLink,
}
impl PinOneReal {
fn send(&mut self, packet: &[u8; pinone::PACKET_LEN]) -> Result<(), String> {
match &mut self.link {
PinOneLink::Serial(s) => s.send(packet),
PinOneLink::Hid(h) => h.send(packet),
}
}
}
fn open_pinone(link: PinOneLink) -> Result<Box<dyn OutputBoard>, String> {
let mut board = PinOneReal { link };
board.all_off()?;
log::info!("{} opened successfully", board.kind().label());
Ok(Box::new(board))
}
impl OutputBoard for PinOneReal {
fn kind(&self) -> BoardKind {
match self.link {
PinOneLink::Serial(_) => BoardKind::PinOne,
PinOneLink::Hid(_) => BoardKind::PinOneUltra,
}
}
fn num_outputs(&self) -> u16 {
match &self.link {
PinOneLink::Serial(s) => s.num_outputs(),
PinOneLink::Hid(h) => h.num_outputs(),
}
}
fn set_output(&mut self, n: u16, intensity: u8) -> Result<(), String> {
let num = self.num_outputs();
let packet = pinone::set_output_packet(n, intensity)
.filter(|_| n <= num)
.ok_or_else(|| format!("output {n} out of range (1..={num})"))?;
self.send(&packet)
}
fn all_off(&mut self) -> Result<(), String> {
self.send(&pinone::all_off_packet())
}
}
pub enum DiscoveryCmd {
StartLoop { output: u16 },
Stop,
Shutdown,
}
pub enum DiscoveryEvent {
#[allow(dead_code)] PulseFired { output: u16, count: u32 },
#[allow(dead_code)]
LoopCompleted { output: u16 },
Error { msg: String },
}
pub struct DiscoveryChannels {
pub cmd_tx: Sender<DiscoveryCmd>,
pub event_rx: Receiver<DiscoveryEvent>,
}
pub fn spawn_discovery_thread(mut board: Box<dyn OutputBoard>) -> DiscoveryChannels {
let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded::<DiscoveryCmd>();
let (event_tx, event_rx) = crossbeam_channel::unbounded::<DiscoveryEvent>();
thread::spawn(move || {
log::info!(
"Output discovery thread started: board={}, outputs={}",
board.kind().label(),
board.num_outputs()
);
let mut active: Option<u16> = None;
let mut count: u32 = 0;
loop {
if active.is_none() {
match cmd_rx.recv() {
Ok(DiscoveryCmd::StartLoop { output }) => {
active = Some(output);
count = 0;
}
Ok(DiscoveryCmd::Stop) => {
let _ = board.all_off();
}
Ok(DiscoveryCmd::Shutdown) | Err(_) => {
let _ = board.all_off();
log::info!("Output discovery thread exiting");
return;
}
}
continue;
}
let out = active.unwrap();
if let Err(e) = board.set_output(out, 255) {
let _ = board.all_off();
let _ = event_tx.send(DiscoveryEvent::Error { msg: e });
active = None;
continue;
}
thread::sleep(Duration::from_millis(PULSE_ON_MS));
if let Err(e) = board.set_output(out, 0) {
let _ = board.all_off();
let _ = event_tx.send(DiscoveryEvent::Error { msg: e });
active = None;
continue;
}
count += 1;
let _ = event_tx.send(DiscoveryEvent::PulseFired { output: out, count });
if count >= MAX_LOOP_PULSES {
let _ = event_tx.send(DiscoveryEvent::LoopCompleted { output: out });
active = None;
count = 0;
continue;
}
match cmd_rx.recv_timeout(Duration::from_millis(PULSE_OFF_MS)) {
Ok(DiscoveryCmd::StartLoop { output: new_out }) => {
active = Some(new_out);
count = 0;
}
Ok(DiscoveryCmd::Stop) => {
let _ = board.all_off();
active = None;
}
Ok(DiscoveryCmd::Shutdown) => {
let _ = board.all_off();
log::info!("Output discovery thread exiting");
return;
}
Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
let _ = board.all_off();
log::info!("Output discovery thread exiting (channel closed)");
return;
}
}
}
});
DiscoveryChannels { cmd_tx, event_rx }
}
pub struct DiscoveryState {
pub board_kind: BoardKind,
pub num_outputs: u16,
pub current: u16,
pub labels: Vec<String>,
pub pulse_count: u32,
pub loop_running: bool,
pub warning_ack: bool,
pub channels: Option<DiscoveryChannels>,
pub last_error: Option<String>,
pub detected: Vec<DetectedBoard>,
pub detect_error: Option<String>,
pub has_scanned: bool,
#[cfg(target_os = "linux")]
pub udev_apply_rx: Option<Receiver<Result<(), String>>>,
#[cfg(target_os = "linux")]
pub udev_apply_status: Option<Result<(), String>>,
}
impl Default for DiscoveryState {
fn default() -> Self {
Self {
board_kind: BoardKind::Simulator,
num_outputs: 0,
current: 1,
labels: Vec::new(),
pulse_count: 0,
loop_running: false,
warning_ack: false,
channels: None,
last_error: None,
detected: Vec::new(),
detect_error: None,
has_scanned: false,
#[cfg(target_os = "linux")]
udev_apply_rx: None,
#[cfg(target_os = "linux")]
udev_apply_status: None,
}
}
}
impl DiscoveryState {
pub fn is_started(&self) -> bool {
self.channels.is_some()
}
pub fn scan_hardware(&mut self) {
self.has_scanned = true;
match detect_boards() {
Ok(boards) => {
self.detected = boards;
self.detect_error = None;
}
Err(e) => {
self.detected.clear();
self.detect_error = Some(e);
}
}
}
#[cfg(target_os = "linux")]
pub fn poll_udev_apply(&mut self) {
if let Some(rx) = self.udev_apply_rx.as_ref()
&& let Ok(result) = rx.try_recv()
{
self.udev_apply_status = Some(result);
self.udev_apply_rx = None;
}
}
#[cfg(not(target_os = "linux"))]
pub fn poll_udev_apply(&mut self) {}
pub fn start(&mut self, board: Box<dyn OutputBoard>) {
self.board_kind = board.kind();
self.num_outputs = board.num_outputs();
self.current = 1;
self.pulse_count = 0;
self.loop_running = false;
self.last_error = None;
self.labels = vec![String::new(); self.num_outputs as usize];
self.channels = Some(spawn_discovery_thread(board));
}
pub fn stop_session(&mut self) {
if let Some(ch) = self.channels.take() {
let _ = ch.cmd_tx.send(DiscoveryCmd::Shutdown);
}
self.loop_running = false;
self.pulse_count = 0;
}
pub fn start_loop(&mut self) {
if let Some(ch) = self.channels.as_ref() {
self.pulse_count = 0;
self.loop_running = true;
self.last_error = None;
let _ = ch.cmd_tx.send(DiscoveryCmd::StartLoop {
output: self.current,
});
}
}
pub fn stop_loop(&mut self) {
if let Some(ch) = self.channels.as_ref() {
let _ = ch.cmd_tx.send(DiscoveryCmd::Stop);
}
self.loop_running = false;
}
pub fn next_output(&mut self) {
if self.num_outputs == 0 {
return;
}
if self.current < self.num_outputs {
self.current += 1;
}
if self.loop_running {
self.start_loop();
}
}
pub fn prev_output(&mut self) {
if self.current > 1 {
self.current -= 1;
}
if self.loop_running {
self.start_loop();
}
}
pub fn current_label_mut(&mut self) -> Option<&mut String> {
if self.current == 0 {
return None;
}
self.labels.get_mut(self.current as usize - 1)
}
pub fn poll_events(&mut self) {
let Some(ch) = self.channels.as_ref() else {
return;
};
while let Ok(ev) = ch.event_rx.try_recv() {
match ev {
DiscoveryEvent::PulseFired { count, .. } => {
self.pulse_count = count;
}
DiscoveryEvent::LoopCompleted { .. } => {
self.loop_running = false;
}
DiscoveryEvent::Error { msg } => {
self.loop_running = false;
self.last_error = Some(msg);
}
}
}
}
pub fn summary_text(&self) -> String {
let mut out = String::new();
for (i, label) in self.labels.iter().enumerate() {
let label = label.trim();
if label.is_empty() {
continue;
}
out.push_str(&format!("Output #{} → {}\n", i + 1, label));
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn build_ledwiz_sba(shadow: &[u8; 32]) -> [u8; 9] {
let mut sba = [0u8; 9];
sba[1] = 0x40;
sba[6] = 0x02;
for i in 0..32 {
if shadow[i] > 127 {
sba[2 + i / 8] |= 1 << (i % 8);
}
}
sba
}
fn build_ledwiz_pba(shadow: &[u8; 32]) -> [[u8; 9]; 4] {
let mut pbas = [[0u8; 9]; 4];
for (group, pba) in pbas.iter_mut().enumerate() {
for i in 0..8 {
let idx = group * 8 + i;
pba[1 + i] = ((shadow[idx] as u32 * 48) / 255) as u8;
}
}
pbas
}
#[test]
fn ledwiz_all_off_sba_matches_libdof() {
let shadow = [0u8; 32];
let sba = build_ledwiz_sba(&shadow);
assert_eq!(sba, [0x00, 0x40, 0, 0, 0, 0, 0x02, 0, 0]);
}
#[test]
fn ledwiz_sba_bit_set_above_threshold() {
let mut shadow = [0u8; 32];
shadow[0] = 255; shadow[7] = 128; shadow[8] = 127; shadow[31] = 200; let sba = build_ledwiz_sba(&shadow);
assert_eq!(sba[2], 0b1000_0001, "bank0 should have bits 0 and 7 set");
assert_eq!(
sba[3], 0b0000_0000,
"bank1 should be empty (bit 8 below threshold)"
);
assert_eq!(sba[5], 0b1000_0000, "bank3 should have bit 31 set");
}
#[test]
fn ledwiz_pba_intensity_maps_0_to_48() {
let mut shadow = [0u8; 32];
shadow[0] = 0;
shadow[1] = 255;
shadow[2] = 128; let pbas = build_ledwiz_pba(&shadow);
assert_eq!(pbas[0][1], 0, "0 → 0");
assert_eq!(pbas[0][2], 48, "255 → 48");
assert_eq!(pbas[0][3], 24, "128 → ~half of 48");
}
#[test]
#[allow(clippy::identity_op)]
fn ledwiz_unit_from_pid_matches_libdof_formula() {
assert_eq!((0x00F0u16 & 0x0F) + 1, 1);
assert_eq!((0x00F1u16 & 0x0F) + 1, 2);
assert_eq!((0x00FFu16 & 0x0F) + 1, 16);
}
#[test]
fn pinscape_extended_cmd_layout() {
let shadow = {
let mut s = [0u8; 22];
s[4] = 255; s
};
let group = 0u16;
let base_idx = (group * OUTPUTS_PER_GROUP) as usize;
let mut report = [0u8; 9];
report[1] = CMD_SET_BRIGHTNESS_BASE + group as u8;
for i in 0..OUTPUTS_PER_GROUP as usize {
report[2 + i] = *shadow.get(base_idx + i).unwrap_or(&0);
}
assert_eq!(report, [0x00, 200, 0, 0, 0, 0, 255, 0, 0]);
}
#[test]
fn pinscape_all_off_cmd() {
let report = [0x00, CMD_EXTENDED, CMD_EXT_ALL_OFF, 0, 0, 0, 0, 0, 0];
assert_eq!(report, [0x00, 65, 5, 0, 0, 0, 0, 0, 0]);
}
#[test]
fn pspico_set_output_layout() {
let mut buf = [0u8; PSPICO_REPORT_LEN];
buf[0] = PSPICO_REPORT_ID;
buf[1] = PSPICO_CMD_SET_OUTPUTS;
buf[2] = 1;
buf[3] = 5; buf[4] = 255;
assert_eq!(buf[0], 4, "report ID = 4");
assert_eq!(buf[1], 0x22, "command = 0x22");
assert_eq!(buf[2], 1, "count = 1");
assert_eq!(buf[3], 5, "port 5");
assert_eq!(buf[4], 255, "full intensity");
assert!(buf[5..].iter().all(|&b| b == 0), "rest zeroed");
assert_eq!(buf.len(), 64, "packet is 64 bytes");
}
#[test]
fn pspico_all_off_layout() {
let mut buf = [0u8; PSPICO_REPORT_LEN];
buf[0] = PSPICO_REPORT_ID;
buf[1] = PSPICO_CMD_ALL_OFF;
assert_eq!(buf[0], 4);
assert_eq!(buf[1], 0x20);
assert!(buf[2..].iter().all(|&b| b == 0));
}
fn build_dudescab_set_output(n: u16, intensity: u8) -> Vec<u8> {
let idx = n - 1;
let ext = (idx / DUDESCAB_OUTPUTS_PER_EXT) as u8;
let bit = idx % DUDESCAB_OUTPUTS_PER_EXT;
let ext_mask = 1u8 << ext;
let out_mask = 1u16 << bit;
vec![
DUDESCAB_RID_OUTPUTS,
DUDESCAB_CMD_OUTPUTS,
0,
1,
4,
ext_mask,
(out_mask & 0xFF) as u8,
((out_mask >> 8) & 0xFF) as u8,
intensity,
]
}
#[test]
fn dudescab_set_output_ext0_bit0() {
let buf = build_dudescab_set_output(1, 255);
assert_eq!(
buf,
vec![3, 102, 0, 1, 4, 0b0000_0001, 0b0000_0001, 0b0000_0000, 255]
);
}
#[test]
fn dudescab_set_output_ext1_bit4() {
let buf = build_dudescab_set_output(21, 128);
assert_eq!(
buf,
vec![3, 102, 0, 1, 4, 0b0000_0010, 0b0001_0000, 0b0000_0000, 128]
);
}
#[test]
fn dudescab_set_output_ext0_bit15() {
let buf = build_dudescab_set_output(16, 64);
assert_eq!(
buf,
vec![3, 102, 0, 1, 4, 0b0000_0001, 0b0000_0000, 0b1000_0000, 64]
);
}
#[test]
fn dudescab_all_off_layout() {
let buf = [DUDESCAB_RID_OUTPUTS, DUDESCAB_CMD_ALLOFF, 0, 1, 0];
assert_eq!(buf, [3, 101, 0, 1, 0]);
}
fn build_pacdrive_state(shadow: u16) -> [u8; 5] {
[
0x00,
0,
0,
((shadow >> 8) & 0xFF) as u8,
(shadow & 0xFF) as u8,
]
}
#[test]
fn pacdrive_all_off_state() {
assert_eq!(build_pacdrive_state(0), [0x00, 0, 0, 0, 0]);
}
#[test]
fn pacdrive_output_1_on() {
let shadow = 1u16 << 0;
assert_eq!(build_pacdrive_state(shadow), [0x00, 0, 0, 0x00, 0x01]);
}
#[test]
fn pacdrive_output_16_on() {
let shadow = 1u16 << 15;
assert_eq!(build_pacdrive_state(shadow), [0x00, 0, 0, 0x80, 0x00]);
}
#[test]
fn pacled_set_output_layout() {
let n: u16 = 5; let intensity: u8 = 200;
let buf = [0x00u8, (n - 1) as u8, intensity];
assert_eq!(buf, [0x00, 0x04, 200]);
}
#[test]
fn pinone_ultra_vendor_collection_only() {
let k = classify_hid(0x39F5, 0x9208, 0xFF00, 0x01, "PinOne Ultra");
assert_eq!(k, Some(BoardKind::PinOneUltra));
assert_eq!(
classify_hid(0x39F5, 0x9300, 0x01, 0x04, "PinOne Ultra"),
Some(BoardKind::PinOneUltra)
);
assert!(is_correct_interface(BoardKind::PinOneUltra, 0xFF00, 0x01));
assert!(!is_correct_interface(BoardKind::PinOneUltra, 0x01, 0x04));
assert!(!is_correct_interface(BoardKind::PinOneUltra, 0x01, 0x06));
assert!(!is_correct_interface(BoardKind::PinOneUltra, 0x0C, 0x01));
}
#[test]
fn pinone_original_is_not_a_hid_output_board() {
assert_eq!(classify_hid(0x0E8F, 0x9207, 0x01, 0x04, "PinOne"), None);
}
#[test]
fn pinone_configtool_guidance() {
let board = DetectedBoard {
kind: BoardKind::PinOne,
vid: 0x0E8F,
pid: 0x9207,
location: BoardLocation::Serial("COM5".into()),
product: String::new(),
num_outputs: 63,
firmware: Some("2.2.1".into()),
problem: None,
};
assert_eq!(board.configtool_controller(), "PinOne");
assert_eq!(
board.expected_config_filename().as_deref(),
Some("directoutputconfig11.ini")
);
assert_eq!(board.firmware_mode(), "2.2.1");
assert_eq!(board.path_str(), "COM5");
assert_eq!(default_outputs(BoardKind::PinOneUltra), 63);
assert!(BoardKind::PinOne.has_driver() && BoardKind::PinOneUltra.has_driver());
}
#[test]
fn pacled_output_port_is_zero_indexed() {
let n: u16 = 1;
let buf = [0x00u8, (n - 1) as u8, 255];
assert_eq!(buf[1], 0, "libdof uses 0-indexed port numbers");
}
}