use super::*;
use crate::outputs_hid::{self, MAX_LOOP_PULSES, OutputBoard};
use std::path::PathBuf;
const DOF_GENERATOR_URL: &str = "https://configtool.vpuniverse.com/app/home";
const SIMULATOR_DEFAULT_OUTPUTS: u16 = 22;
const UDEV_RULES_PINSCAPE: &str = "\
# /etc/udev/rules.d/99-pinscape.rules
# Pinscape KL25Z / Pico — USB + hidraw access for non-root users.
# Covers both LedWiz-emulation VID (FAFA) and private pid.codes (1209:EAEA).
# LedWiz emulation mode
SUBSYSTEM==\"usb\", ATTRS{idVendor}==\"fafa\", MODE=\"0666\"
SUBSYSTEM==\"hidraw\", ATTRS{idVendor}==\"fafa\", MODE=\"0666\"
# Private pid.codes (Pinscape Pico and KL25Z in private mode)
SUBSYSTEM==\"usb\", ATTRS{idVendor}==\"1209\", ATTRS{idProduct}==\"eaea\", MODE=\"0666\"
SUBSYSTEM==\"hidraw\", ATTRS{idVendor}==\"1209\", ATTRS{idProduct}==\"eaea\", MODE=\"0666\"
# CSD PinOne: outputs on its USB serial port (original board)
SUBSYSTEM==\"tty\", ATTRS{idVendor}==\"0e8f\", ATTRS{idProduct}==\"9207\", MODE=\"0666\"
# CSD PinOne Ultra: outputs on its HID vendor channel
SUBSYSTEM==\"hidraw\", ATTRS{idVendor}==\"39f5\", MODE=\"0666\"
";
const ASTERISK_RED_OUT: egui::Color32 = egui::Color32::from_rgb(255, 80, 80);
const NOTICE_AMBER_OUT: egui::Color32 = egui::Color32::from_rgb(255, 200, 80);
fn dof_config_dir() -> PathBuf {
#[cfg(target_os = "linux")]
{
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".local/share/VPinballX/10.8/directoutputconfig")
}
#[cfg(target_os = "macos")]
{
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join("Library/Application Support/VPinballX/10.8/directoutputconfig")
}
#[cfg(target_os = "windows")]
{
let appdata = std::env::var("APPDATA").unwrap_or_else(|_| {
let home = std::env::var("USERPROFILE").unwrap_or_else(|_| ".".into());
format!("{home}\\AppData\\Roaming")
});
PathBuf::from(appdata).join("VPinballX\\10.8\\directoutputconfig")
}
}
fn open_in_file_manager(path: &std::path::Path) {
#[cfg(target_os = "linux")]
let cmd = "xdg-open";
#[cfg(target_os = "macos")]
let cmd = "open";
#[cfg(target_os = "windows")]
let cmd = "explorer";
if let Err(e) = std::process::Command::new(cmd).arg(path).spawn() {
log::error!("Failed to open {}: {e}", path.display());
}
}
impl App {
pub(super) fn render_outputs_page(&mut self, ui: &mut egui::Ui) {
theme::page_header(ui, &t!("outputs_heading"), Some(&t!("outputs_intro")));
theme::section(
ui,
&t!("outputs_dof_subtitle"),
Some(&t!("outputs_desc")),
|ui| {
theme::field_label(ui, &t!("outputs_workflow_title"));
ui.label(format!("1. {}", t!("outputs_step1_account")));
ui.label(format!("2. {}", t!("outputs_step2_table")));
ui.label(format!("3. {}", t!("outputs_step3_output_system")));
ui.label(format!("4. {}", t!("outputs_step4_devices")));
ui.label(format!("5. {}", t!("outputs_step5_generate")));
ui.label(format!("6. {}", t!("outputs_step6_extract")));
ui.add_space(10.0);
ui.label(t!("outputs_devices_intro"));
ui.add_space(2.0);
ui.horizontal_wrapped(|ui| {
for key in [
"outputs_device_solenoids",
"outputs_device_flashers",
"outputs_device_gi",
"outputs_device_rgb_buttons",
"outputs_device_rgb_strips",
"outputs_device_shaker",
"outputs_device_knocker",
"outputs_device_beacon",
"outputs_device_gear",
] {
ui.label(format!("• {}", t!(key)));
ui.add_space(8.0);
}
});
ui.add_space(12.0);
theme::field_label(ui, &t!("outputs_generator_title"));
ui.add_space(4.0);
ui.label(t!("outputs_generator_desc"));
ui.add_space(4.0);
ui.hyperlink_to(
format!("🌐 {}", t!("outputs_open_generator")),
DOF_GENERATOR_URL,
);
ui.add_space(12.0);
theme::field_label(ui, &t!("outputs_target_title"));
ui.add_space(4.0);
ui.label(t!("outputs_target_desc"));
ui.add_space(4.0);
let dof_dir = dof_config_dir();
ui.monospace(dof_dir.display().to_string());
ui.add_space(6.0);
let exists = dof_dir.is_dir();
ui.horizontal(|ui| {
let btn_label = format!("📁 {}", t!("outputs_open_folder"));
if ui
.button(btn_label)
.on_hover_text(t!("outputs_open_folder_hint"))
.clicked()
{
if !exists && let Err(e) = std::fs::create_dir_all(&dof_dir) {
log::error!("Failed to create DOF config dir: {e}");
}
open_in_file_manager(&dof_dir);
}
if !exists {
ui.colored_label(
theme::pal(ui.ctx()).warning,
t!("outputs_folder_missing"),
);
}
});
},
);
theme::section(
ui,
&t!("outputs_discover_title"),
Some(&t!("outputs_discover_desc")),
|ui| {
self.render_output_discovery(ui);
},
);
theme::section(
ui,
&t!("outputs_dof_enable_title"),
Some(&t!("outputs_dof_enable_desc")),
|ui| {
self.render_dof_enable(ui);
},
);
theme::section(
ui,
&t!("ht_section_title"),
Some(&t!("ht_section_desc")),
|ui| {
self.render_headtracking_section(ui);
},
);
}
fn render_headtracking_section(&mut self, ui: &mut egui::Ui) {
use crate::headtracking_install as ht;
let mut done = false;
if let Some(rx) = &self.ht_install_rx {
while let Ok(ev) = rx.try_recv() {
match ev {
ht::HtEvent::Status(key) => self.ht_status_key = Some(key),
ht::HtEvent::Done { tag } => {
self.ht_done_tag = Some(tag);
self.ht_installed_version = None; self.ht_enabled = Some(true);
done = true;
}
ht::HtEvent::Error(msg) => {
self.ht_error = Some(msg);
done = true;
}
}
}
}
if done {
self.ht_install_rx = None;
self.ht_status_key = None;
let install_dir = std::path::PathBuf::from(&self.vpx_install_dir);
if self.ht_error.is_none() {
if let Err(e) = ht::launch_demo(&install_dir) {
self.ht_error = Some(format!("{e:#}"));
}
}
}
let install_dir = std::path::PathBuf::from(&self.vpx_install_dir);
let installed = self
.ht_installed_version
.get_or_insert_with(|| ht::installed_version(&install_dir))
.clone();
let supported = ht::platform_asset_suffix().is_some();
let running = self.ht_install_rx.is_some();
if supported && self.ht_latest_rx.is_none() && self.ht_latest_version.is_none() {
self.ht_latest_rx = Some(ht::spawn_latest_version());
}
if let Some(rx) = &self.ht_latest_rx {
if let Ok(version) = rx.try_recv() {
self.ht_latest_version = Some(version);
self.ht_latest_rx = None;
} else {
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(300));
}
}
let latest = self.ht_latest_version.clone().flatten();
ui.horizontal(|ui| {
let label = match (&installed, &latest) {
(Some(from), Some(to)) if from != to => t!(
"ht_update_button_to",
from = from.as_str(),
to = to.as_str()
)
.to_string(),
(Some(v), Some(_)) => t!("ht_reinstall_button", version = v.as_str()).to_string(),
(Some(v), None) => t!("ht_update_button", version = v.as_str()).to_string(),
(None, Some(to)) => {
t!("ht_install_button_version", version = to.as_str()).to_string()
}
(None, None) => t!("ht_install_button").to_string(),
};
let clicked = ui
.add_enabled(supported && !running, egui::Button::new(label))
.on_disabled_hover_text(if supported {
t!("ht_status_installing").to_string()
} else {
t!("ht_unsupported_platform").to_string()
})
.clicked();
if clicked {
self.ht_error = None;
self.ht_done_tag = None;
self.ht_install_rx = Some(ht::spawn_install(install_dir.clone()));
}
if installed.is_some()
&& ui
.add_enabled(!running, egui::Button::new(t!("ht_launch_demo")))
.clicked()
&& let Err(e) = ht::launch_demo(&install_dir)
{
self.ht_error = Some(format!("{e:#}"));
}
});
if installed.is_some() {
let default_on = self
.config
.get_i32("Plugin.HeadTracking", "Enable")
.map(|v| v != 0)
.unwrap_or(true);
let enabled = self.ht_enabled.get_or_insert(default_on);
ui.checkbox(enabled, t!("ht_enable"))
.on_hover_text(t!("ht_enable_help"));
}
if let Some(key) = self.ht_status_key {
ui.colored_label(egui::Color32::from_gray(170), t!(key));
ui.ctx().request_repaint();
}
if let Some(tag) = &self.ht_done_tag {
ui.colored_label(
egui::Color32::from_rgb(120, 200, 120),
t!("ht_done", tag = tag.as_str()),
);
}
if let Some(err) = &self.ht_error {
ui.colored_label(ASTERISK_RED_OUT, format!("⚠ {err}"));
}
if !supported {
ui.colored_label(NOTICE_AMBER_OUT, t!("ht_unsupported_platform"));
}
}
fn render_dof_enable(&mut self, ui: &mut egui::Ui) {
let enabled = matches!(
self.config.get("Plugin.DOF", "Enable").as_deref(),
Some("1" | "true" | "on")
);
if enabled {
ui.colored_label(
egui::Color32::from_rgb(120, 220, 120),
t!("outputs_dof_enable_status_on"),
);
ui.add_space(4.0);
if ui
.button(t!("outputs_dof_enable_disable"))
.on_hover_text(t!("outputs_dof_disable_hint"))
.clicked()
{
self.config.set("Plugin.DOF", "Enable", "");
if let Err(e) = self.config.save() {
log::error!("Failed to save VPX ini after disabling DOF: {e}");
}
}
} else {
ui.colored_label(
egui::Color32::from_rgb(255, 180, 50),
t!("outputs_dof_enable_status_off"),
);
ui.add_space(4.0);
if ui
.button(t!("outputs_dof_enable_activate"))
.on_hover_text(t!("outputs_dof_activate_hint"))
.clicked()
{
self.config.set("Plugin.DOF", "Enable", "1");
match self.config.save() {
Ok(()) => log::info!("[Plugin.DOF] Enable = 1 written to VPX ini"),
Err(e) => log::error!("Failed to save VPX ini after enabling DOF: {e}"),
}
}
}
}
fn render_output_discovery(&mut self, ui: &mut egui::Ui) {
self.output_discovery.poll_events();
self.output_discovery.poll_udev_apply();
ui.colored_label(
egui::Color32::from_rgb(255, 180, 50),
t!("outputs_discover_safety"),
);
ui.add_space(8.0);
if self.output_discovery.is_started() {
self.render_discovery_session(ui);
} else {
self.render_discovery_start(ui);
}
}
fn render_discovery_start(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.checkbox(
&mut self.output_discovery.warning_ack,
t!("outputs_discover_ack"),
);
help_marker(ui, &t!("outputs_discover_ack_hint"));
});
ui.add_space(6.0);
let can_start = self.output_discovery.warning_ack;
ui.horizontal(|ui| {
let scan_label = if self.output_discovery.has_scanned {
t!("outputs_discover_rescan")
} else {
t!("outputs_discover_scan")
};
if ui
.add_enabled(can_start, egui::Button::new(scan_label))
.on_hover_text(t!("outputs_discover_scan_hint"))
.clicked()
{
self.output_discovery.scan_hardware();
}
if ui
.add_enabled(
can_start,
egui::Button::new(t!("outputs_discover_start_simulator")),
)
.on_hover_text(t!("outputs_discover_simulator_hint"))
.clicked()
{
let board: Box<dyn OutputBoard> =
Box::new(outputs_hid::Simulator::new(SIMULATOR_DEFAULT_OUTPUTS));
self.output_discovery.start(board);
}
});
ui.add_space(6.0);
if let Some(err) = self.output_discovery.detect_error.clone() {
ui.colored_label(
egui::Color32::from_rgb(255, 120, 120),
t!("outputs_discover_scan_error", msg = err.clone()),
);
if err.to_lowercase().contains("permission") || err.to_lowercase().contains("access") {
ui.add_space(4.0);
self.render_udev_apply_controls(ui);
}
} else if self.output_discovery.has_scanned && self.output_discovery.detected.is_empty() {
ui.colored_label(
egui::Color32::from_rgb(255, 180, 50),
t!("outputs_discover_scan_empty"),
);
}
let detected = self.output_discovery.detected.clone();
for board_info in detected {
ui.add_space(4.0);
ui.separator();
ui.label(format!(
"{} — VID {:04X} / PID {:04X}",
board_info.kind.label(),
board_info.vid,
board_info.pid
));
if !board_info.product.is_empty() {
ui.weak(&board_info.product);
}
ui.weak(board_info.path_str());
ui.add_space(4.0);
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.strong(t!("outputs_discover_configtool_title"));
ui.add_space(2.0);
let mode = board_info.firmware_mode();
if !mode.is_empty() {
ui.label(format!(
"{} {}",
t!("outputs_discover_configtool_firmware"),
mode
));
}
ui.label(format!(
"{} {}",
t!("outputs_discover_configtool_controller"),
board_info.configtool_controller()
));
if let Some(file) = board_info.expected_config_filename() {
ui.horizontal(|ui| {
ui.label(t!("outputs_discover_configtool_file"));
ui.monospace(&file);
});
}
});
ui.add_space(4.0);
if board_info.kind == outputs_hid::BoardKind::PinOneUltra {
ui.label(t!("outputs_discover_pinone_ultra_dof"));
ui.add_space(4.0);
}
if let Some(problem) = &board_info.problem {
let port = board_info.path_str();
let msg = match problem {
outputs_hid::BoardProblem::Busy => {
t!("outputs_discover_pinone_busy", port = port)
}
outputs_hid::BoardProblem::PermissionDenied => {
t!("outputs_discover_pinone_permission", port = port)
}
outputs_hid::BoardProblem::NoAnswer => {
t!("outputs_discover_pinone_no_answer", port = port)
}
outputs_hid::BoardProblem::Other(e) => {
t!("outputs_discover_error", msg = format!("{port}: {e}"))
}
};
ui.colored_label(egui::Color32::from_rgb(255, 180, 50), msg);
if *problem == outputs_hid::BoardProblem::PermissionDenied {
ui.add_space(4.0);
self.render_udev_apply_controls(ui);
}
ui.add_space(4.0);
}
if board_info.kind.has_driver() {
if ui
.add_enabled(
can_start && board_info.problem.is_none(),
egui::Button::new(t!(
"outputs_discover_start_hardware",
n = board_info.num_outputs
)),
)
.on_hover_text(t!("outputs_discover_start_hardware_hint"))
.clicked()
{
match outputs_hid::open_board(
board_info.kind,
&board_info.location,
board_info.num_outputs,
) {
Ok(board) => self.output_discovery.start(board),
Err(e) => {
self.output_discovery.detect_error = Some(e);
}
}
}
} else {
ui.add_enabled(false, egui::Button::new(t!("outputs_discover_driver_soon")))
.on_disabled_hover_text(t!("outputs_discover_driver_soon_tooltip"));
}
}
}
fn render_udev_apply_controls(&mut self, ui: &mut egui::Ui) {
#[cfg(target_os = "linux")]
{
if matches!(self.output_discovery.udev_apply_status, Some(Ok(()))) {
self.output_discovery.udev_apply_status = None;
self.output_discovery.detect_error = None;
self.output_discovery.scan_hardware();
return;
}
let busy = self.output_discovery.udev_apply_rx.is_some();
let label = if busy {
t!("outputs_discover_apply_udev_busy")
} else {
t!("outputs_discover_apply_udev")
};
ui.horizontal(|ui| {
if ui
.add_enabled(!busy, egui::Button::new(label))
.on_hover_text(t!("outputs_discover_apply_udev_tooltip"))
.clicked()
{
let rx = crate::outputs_hid::spawn_udev_apply(UDEV_RULES_PINSCAPE.to_string());
self.output_discovery.udev_apply_rx = Some(rx);
self.output_discovery.udev_apply_status = None;
}
if busy {
ui.spinner();
ui.ctx().request_repaint();
}
});
if let Some(Err(e)) = &self.output_discovery.udev_apply_status {
ui.add_space(4.0);
ui.colored_label(
egui::Color32::from_rgb(255, 120, 120),
t!("outputs_discover_apply_udev_err", msg = e.clone()),
);
}
}
#[cfg(not(target_os = "linux"))]
{
if ui
.button(t!("outputs_discover_copy_udev"))
.on_hover_text(t!("outputs_discover_copy_udev_hint"))
.clicked()
{
ui.ctx().output_mut(|o| {
o.commands.push(egui::OutputCommand::CopyText(
UDEV_RULES_PINSCAPE.to_string(),
))
});
}
}
}
fn render_discovery_session(&mut self, ui: &mut egui::Ui) {
let s = &self.output_discovery;
ui.label(format!(
"{} — {} {}",
s.board_kind.label(),
s.num_outputs,
t!("outputs_discover_outputs_suffix")
));
ui.add_space(6.0);
ui.label(t!(
"outputs_discover_status",
n = s.current,
total = s.num_outputs,
pulse = s.pulse_count,
max = MAX_LOOP_PULSES
));
let pct = (s.pulse_count as f32 / MAX_LOOP_PULSES as f32).clamp(0.0, 1.0);
ui.add(egui::ProgressBar::new(pct).desired_width(300.0));
ui.add_space(4.0);
if !s.loop_running && s.pulse_count == 0 {
ui.weak(t!("outputs_discover_idle"));
} else if !s.loop_running && s.pulse_count >= MAX_LOOP_PULSES {
ui.colored_label(
egui::Color32::from_rgb(255, 180, 50),
t!("outputs_discover_loop_complete", max = MAX_LOOP_PULSES),
);
}
if let Some(err) = &s.last_error {
ui.colored_label(
egui::Color32::from_rgb(255, 120, 120),
t!("outputs_discover_error", msg = err.clone()),
);
}
ui.add_space(6.0);
ui.horizontal(|ui| {
if self.output_discovery.loop_running {
if ui
.button(t!("outputs_discover_pulse_stop"))
.on_hover_text(t!("outputs_discover_pulse_stop_hint"))
.clicked()
{
self.output_discovery.stop_loop();
}
} else if ui
.button(t!("outputs_discover_pulse_start"))
.on_hover_text(t!("outputs_discover_pulse_start_hint"))
.clicked()
{
self.output_discovery.start_loop();
}
let can_replay = self.output_discovery.num_outputs > 0;
if ui
.add_enabled(can_replay, egui::Button::new(t!("outputs_discover_replay")))
.on_hover_text(t!("outputs_discover_replay_hint"))
.clicked()
{
self.output_discovery.start_loop();
}
let can_prev = self.output_discovery.current > 1;
if ui
.add_enabled(can_prev, egui::Button::new(t!("outputs_discover_prev")))
.on_hover_text(t!("outputs_discover_prev_hint"))
.clicked()
{
self.output_discovery.prev_output();
}
let can_next = self.output_discovery.current < self.output_discovery.num_outputs;
if ui
.add_enabled(can_next, egui::Button::new(t!("outputs_discover_next")))
.on_hover_text(t!("outputs_discover_next_hint"))
.clicked()
{
self.output_discovery.next_output();
}
});
ui.add_space(8.0);
let current = self.output_discovery.current;
ui.label(t!("outputs_discover_label_for", n = current));
if let Some(label) = self.output_discovery.current_label_mut() {
ui.add(
egui::TextEdit::singleline(label)
.hint_text(t!("outputs_discover_label_placeholder"))
.desired_width(400.0),
)
.on_hover_text(t!("outputs_discover_label_hint"));
}
ui.add_space(12.0);
ui.strong(t!("outputs_discover_summary"));
let summary = self.output_discovery.summary_text();
if summary.is_empty() {
ui.weak(t!("outputs_discover_summary_empty"));
} else {
ui.monospace(&summary);
}
ui.add_space(8.0);
ui.horizontal(|ui| {
if !summary.is_empty()
&& ui
.button(t!("outputs_discover_copy"))
.on_hover_text(t!("outputs_discover_copy_hint"))
.clicked()
{
ui.ctx().output_mut(|o| {
o.commands
.push(egui::OutputCommand::CopyText(summary.clone()))
});
}
if ui
.button(t!("outputs_discover_end_session"))
.on_hover_text(t!("outputs_discover_end_session_hint"))
.clicked()
{
self.output_discovery.stop_session();
self.output_discovery = crate::outputs_hid::DiscoveryState::default();
}
});
if self.output_discovery.loop_running {
ui.ctx().request_repaint();
}
}
}