use crate::backend::{DacBackend, FrameSwapBackend, WriteOutcome};
use crate::device::{DacCapabilities, DacType};
use crate::error::{Error, Result};
use crate::point::LaserPoint;
use crate::protocols::helios::{
bulk_transfer_timeout, encode_frame_into, DeviceStatus, HeliosDac, HeliosDacController,
HeliosDacError, Point as HeliosPoint, WriteFrameFlags,
};
const STATUS_ERROR_LIMIT: u32 = 50;
pub struct HeliosBackend {
dac: Option<HeliosDac>,
device_index: usize,
usb_location: Option<String>,
caps: DacCapabilities,
status_error_count: u32,
fatal_disconnect: bool,
oversize_clamp_count: u32,
pps_clamp_count: u32,
point_buffer: Vec<HeliosPoint>,
frame_buffer: Vec<u8>,
}
impl HeliosBackend {
pub fn new(device_index: usize) -> Self {
Self {
dac: None,
device_index,
usb_location: None,
caps: super::default_capabilities(),
status_error_count: 0,
fatal_disconnect: false,
oversize_clamp_count: 0,
pps_clamp_count: 0,
point_buffer: Vec::new(),
frame_buffer: Vec::new(),
}
}
pub fn from_dac(dac: HeliosDac) -> Self {
let usb_location = Some(dac.usb_location());
Self {
dac: Some(dac),
device_index: 0,
usb_location,
caps: super::default_capabilities(),
status_error_count: 0,
fatal_disconnect: false,
oversize_clamp_count: 0,
pps_clamp_count: 0,
point_buffer: Vec::new(),
frame_buffer: Vec::new(),
}
}
pub fn discover() -> Result<Vec<HeliosDac>> {
let controller = HeliosDacController::new().map_err(Self::map_err)?;
controller.list_devices().map_err(Self::map_err)
}
fn close_handle(&mut self) {
self.dac = None;
}
fn leak_handle(&mut self) {
if let Some(dac) = self.dac.take() {
dac.leak_handle();
}
}
fn is_fatal_usb_error(e: &HeliosDacError) -> bool {
matches!(
e,
HeliosDacError::UsbError(rusb::Error::NoDevice | rusb::Error::Io | rusb::Error::Pipe)
)
}
fn is_access_error(e: &HeliosDacError) -> bool {
matches!(e, HeliosDacError::UsbError(rusb::Error::Access))
}
fn map_err(e: HeliosDacError) -> Error {
if Self::is_access_error(&e) {
Error::usb_permission_denied("helios")
} else if Self::is_fatal_usb_error(&e) {
Error::disconnected(format!("USB device error: {e}"))
} else {
Error::backend(std::io::Error::other(e.to_string()))
}
}
fn map_err_with_context(context: impl Into<String>, e: HeliosDacError) -> Error {
let context = context.into();
if Self::is_access_error(&e) {
Error::usb_permission_denied(context)
} else if Self::is_fatal_usb_error(&e) {
Error::disconnected(format!("{context}: USB device error: {e}"))
} else {
Error::backend(std::io::Error::other(format!("{context}: {e}")))
}
}
fn mark_status_ok(&mut self) {
if self.status_error_count >= 10 {
log::debug!(
"helios: status polling recovered after {} failed poll(s)",
self.status_error_count
);
}
self.status_error_count = 0;
}
fn mark_status_error(&mut self, location: &str, e: &HeliosDacError) {
self.status_error_count += 1;
if self.status_error_count.is_multiple_of(10) {
log::debug!(
"helios: {} consecutive status poll failures at {location}: {e}",
self.status_error_count
);
}
}
fn on_status_error(&mut self, location: &str, e: &HeliosDacError) {
if Self::is_fatal_usb_error(e) {
log::warn!("helios: fatal status poll failure at {location}: {e}");
self.fatal_disconnect = true;
self.leak_handle();
} else {
self.mark_status_error(location, e);
if self.status_error_count >= STATUS_ERROR_LIMIT {
log::warn!(
"helios: closing backend after {} consecutive status poll failures at {location}: {e}",
self.status_error_count
);
self.close_handle();
}
}
}
fn map_err_ctx(&mut self, context: impl Into<String>, e: HeliosDacError) -> Error {
if Self::is_fatal_usb_error(&e) {
self.fatal_disconnect = true;
}
Self::map_err_with_context(context, e)
}
fn note_oversize_frame(&mut self, requested: usize, max: usize) {
self.oversize_clamp_count += 1;
if self.oversize_clamp_count == 1 || self.oversize_clamp_count.is_multiple_of(256) {
log::warn!(
"helios: frame of {requested} points exceeds device maximum {max}; truncating (occurrence {})",
self.oversize_clamp_count
);
}
}
fn clamp_pps(&mut self, pps: u32) -> u32 {
let clamped = pps.clamp(self.caps.pps_min, self.caps.pps_max);
if clamped != pps {
self.pps_clamp_count += 1;
if self.pps_clamp_count == 1 || self.pps_clamp_count.is_multiple_of(256) {
log::debug!(
"helios: pps {pps} out of range [{}, {}]; clamping to {clamped} (occurrence {})",
self.caps.pps_min,
self.caps.pps_max,
self.pps_clamp_count
);
}
}
clamped
}
fn prepare_frame_buffer(&mut self, pps: u32, points: &[LaserPoint]) -> (u32, usize, usize) {
let max_points = self.caps.max_points_per_chunk;
let points = if points.len() > max_points {
self.note_oversize_frame(points.len(), max_points);
&points[..max_points]
} else {
points
};
let pps = self.clamp_pps(pps);
self.point_buffer.clear();
self.point_buffer
.extend(points.iter().map(HeliosPoint::from));
encode_frame_into(
pps,
&self.point_buffer,
WriteFrameFlags::SINGLE_MODE,
&mut self.frame_buffer,
);
(pps, points.len(), self.frame_buffer.len())
}
pub fn firmware_version(&self) -> Option<u32> {
self.dac
.as_ref()
.and_then(HeliosDac::probed_firmware_version)
}
}
impl DacBackend for HeliosBackend {
fn dac_type(&self) -> DacType {
DacType::Helios
}
fn caps(&self) -> &DacCapabilities {
&self.caps
}
fn connect(&mut self) -> Result<()> {
self.status_error_count = 0;
self.fatal_disconnect = false;
if let Some(dac) = self.dac.take() {
let opened = dac.open().map_err(Self::map_err)?;
self.usb_location = Some(opened.usb_location());
self.dac = Some(opened);
return Ok(());
}
let controller = HeliosDacController::new().map_err(Self::map_err)?;
let dacs = controller.list_devices().map_err(Self::map_err)?;
let dac = if let Some(target) = self.usb_location.clone() {
dacs.into_iter()
.find(|d| d.usb_location() == target)
.ok_or_else(|| {
Error::disconnected(format!("Helios device at USB location {target} not found"))
})?
} else {
let mut dacs = dacs;
if self.device_index >= dacs.len() {
return Err(Error::disconnected(format!(
"Device index {} out of range (found {} devices)",
self.device_index,
dacs.len()
)));
}
dacs.remove(self.device_index)
};
let opened = dac.open().map_err(Self::map_err)?;
self.usb_location = Some(opened.usb_location());
self.dac = Some(opened);
Ok(())
}
fn disconnect(&mut self) -> Result<()> {
self.close_handle();
Ok(())
}
fn is_connected(&self) -> bool {
matches!(&self.dac, Some(HeliosDac::Open { .. }))
}
fn stop(&mut self) -> Result<()> {
let result = match &self.dac {
Some(dac) => dac.stop(),
None => return Ok(()),
};
match result {
Ok(()) => Ok(()),
Err(e) => Err(self.map_err_ctx("helios stop", e)),
}
}
fn set_shutter(&mut self, open: bool) -> Result<()> {
let result = match &self.dac {
Some(dac) => dac.set_shutter(open),
None => return Ok(()),
};
match result {
Ok(()) => Ok(()),
Err(e) => Err(self.map_err_ctx("helios set_shutter", e)),
}
}
}
impl Drop for HeliosBackend {
fn drop(&mut self) {
if !self.fatal_disconnect {
log::debug!("helios: leaking USB handle at teardown (no observed disconnect)");
}
self.leak_handle();
}
}
impl FrameSwapBackend for HeliosBackend {
fn frame_capacity(&self) -> usize {
self.caps.max_points_per_chunk
}
fn is_ready_for_frame(&mut self) -> bool {
let Some(dac) = self.dac.as_mut() else {
return false;
};
let location = dac.usb_location();
match dac.status() {
Ok(DeviceStatus::Ready) => {
self.mark_status_ok();
true
}
Ok(DeviceStatus::NotReady) => {
self.mark_status_ok();
false
}
Err(e) => {
self.on_status_error(&location, &e);
false
}
}
}
fn write_frame(&mut self, pps: u32, points: &[LaserPoint]) -> Result<WriteOutcome> {
let status = {
let dac = self
.dac
.as_ref()
.ok_or_else(|| Error::disconnected("Not connected"))?;
dac.status()
};
match status {
Ok(DeviceStatus::Ready) => {}
Ok(DeviceStatus::NotReady) => return Ok(WriteOutcome::WouldBlock),
Err(e) => return Err(self.map_err_ctx("helios write_frame status poll", e)),
}
self.write_frame_ready(pps, points)
}
fn write_frame_ready(&mut self, pps: u32, points: &[LaserPoint]) -> Result<WriteOutcome> {
let (pps, num_points, byte_len) = self.prepare_frame_buffer(pps, points);
let write_result = {
let dac = self
.dac
.as_mut()
.ok_or_else(|| Error::disconnected("Not connected"))?;
dac.write_frame_buffer(&self.frame_buffer)
};
match write_result {
Ok(()) => Ok(WriteOutcome::Written),
Err(e) => {
let context = format!(
"helios write_frame bulk transfer (pps={pps}, points={num_points}, bytes={byte_len}, timeout_ms={})",
bulk_transfer_timeout(byte_len).as_millis()
);
Err(self.map_err_ctx(context, e))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn map_err_usb_no_device_is_disconnected() {
let err = HeliosBackend::map_err(HeliosDacError::UsbError(rusb::Error::NoDevice));
assert!(err.is_disconnected());
}
#[test]
fn map_err_usb_io_is_disconnected() {
let err = HeliosBackend::map_err(HeliosDacError::UsbError(rusb::Error::Io));
assert!(err.is_disconnected());
}
#[test]
fn map_err_usb_pipe_is_disconnected() {
let err = HeliosBackend::map_err(HeliosDacError::UsbError(rusb::Error::Pipe));
assert!(err.is_disconnected());
}
#[test]
fn map_err_usb_timeout_is_backend() {
let err = HeliosBackend::map_err(HeliosDacError::UsbError(rusb::Error::Timeout));
assert!(!err.is_disconnected());
assert!(matches!(err, Error::Backend(_)));
}
#[test]
fn map_err_device_not_opened_is_backend() {
let err = HeliosBackend::map_err(HeliosDacError::DeviceNotOpened);
assert!(!err.is_disconnected());
assert!(matches!(err, Error::Backend(_)));
}
#[test]
fn map_err_usb_access_is_permission_denied() {
let err = HeliosBackend::map_err(HeliosDacError::UsbError(rusb::Error::Access));
assert!(err.is_permission_denied());
assert!(!err.is_disconnected());
assert!(!matches!(err, Error::Backend(_)));
}
#[test]
fn map_err_ctx_usb_access_is_permission_denied_and_not_fatal() {
let mut backend = HeliosBackend::new(0);
let err = backend.map_err_ctx(
"helios connect",
HeliosDacError::UsbError(rusb::Error::Access),
);
assert!(err.is_permission_denied());
assert!(!backend.fatal_disconnect);
}
#[test]
fn map_err_ctx_fatal_error_flags_fatal_disconnect() {
let mut backend = HeliosBackend::new(0);
let err = backend.map_err_ctx("ctx", HeliosDacError::UsbError(rusb::Error::NoDevice));
assert!(err.is_disconnected());
assert!(
backend.fatal_disconnect,
"a fatal USB error mapped with context must flag Drop to leak the handle"
);
}
#[test]
fn map_err_ctx_nonfatal_error_leaves_fatal_disconnect_clear() {
let mut backend = HeliosBackend::new(0);
let err = backend.map_err_ctx("ctx", HeliosDacError::UsbError(rusb::Error::Timeout));
assert!(!err.is_disconnected());
assert!(!backend.fatal_disconnect);
}
fn assert_fatal_status_error(e: rusb::Error) {
let mut backend = HeliosBackend::new(0);
assert!(!backend.fatal_disconnect);
backend.on_status_error("loc", &HeliosDacError::UsbError(e));
assert!(
backend.fatal_disconnect,
"fatal USB error {e:?} must set fatal_disconnect"
);
}
#[test]
fn status_error_no_device_flags_fatal_disconnect() {
assert_fatal_status_error(rusb::Error::NoDevice);
}
#[test]
fn status_error_io_flags_fatal_disconnect() {
assert_fatal_status_error(rusb::Error::Io);
}
#[test]
fn status_error_pipe_flags_fatal_disconnect() {
assert_fatal_status_error(rusb::Error::Pipe);
}
#[test]
fn status_error_timeout_is_nonfatal_and_counted() {
let mut backend = HeliosBackend::new(0);
backend.on_status_error("loc", &HeliosDacError::UsbError(rusb::Error::Timeout));
assert!(
!backend.fatal_disconnect,
"a timeout is non-fatal and must not flag a fatal disconnect"
);
assert_eq!(backend.status_error_count, 1);
}
#[test]
fn repeated_nonfatal_status_errors_reach_close_limit() {
let mut backend = HeliosBackend::new(0);
for _ in 0..STATUS_ERROR_LIMIT {
backend.on_status_error("loc", &HeliosDacError::UsbError(rusb::Error::Timeout));
}
assert_eq!(backend.status_error_count, STATUS_ERROR_LIMIT);
assert!(!backend.fatal_disconnect, "timeouts stay non-fatal");
assert!(!backend.is_connected());
}
#[test]
fn successful_status_poll_resets_nonfatal_error_count() {
let mut backend = HeliosBackend::new(0);
backend.on_status_error("loc", &HeliosDacError::UsbError(rusb::Error::Timeout));
assert_eq!(backend.status_error_count, 1);
backend.mark_status_ok();
assert_eq!(backend.status_error_count, 0);
}
#[test]
fn oversized_frame_is_truncated_to_device_maximum() {
let mut backend = HeliosBackend::new(0);
let max = backend.caps.max_points_per_chunk;
let points = vec![LaserPoint::new(0.0, 0.0, 65535, 0, 0, 65535); max + 500];
let (_pps, num_points, byte_len) = backend.prepare_frame_buffer(30_000, &points);
assert_eq!(num_points, max);
assert_eq!(backend.point_buffer.len(), max);
assert_eq!(byte_len, max * 7 + 5);
let footer = &backend.frame_buffer[backend.frame_buffer.len() - 5..];
assert_eq!(u16::from_le_bytes([footer[2], footer[3]]) as usize, max);
assert_eq!(backend.oversize_clamp_count, 1);
}
#[test]
fn pps_above_caps_is_clamped_before_encoding() {
let mut backend = HeliosBackend::new(0);
let pps_max = backend.caps.pps_max;
let points = vec![LaserPoint::new(0.0, 0.0, 65535, 0, 0, 65535)];
let (pps, _num, _bytes) = backend.prepare_frame_buffer(10_000_000, &points);
assert_eq!(pps, pps_max);
let footer = &backend.frame_buffer[backend.frame_buffer.len() - 5..];
assert_eq!(u16::from_le_bytes([footer[0], footer[1]]) as u32, pps_max);
assert_eq!(backend.pps_clamp_count, 1);
}
#[test]
fn pps_below_caps_is_clamped_up() {
let mut backend = HeliosBackend::new(0);
let pps_min = backend.caps.pps_min;
let points = vec![LaserPoint::new(0.0, 0.0, 65535, 0, 0, 65535)];
let (pps, _num, _bytes) = backend.prepare_frame_buffer(1, &points);
assert_eq!(pps, pps_min);
let footer = &backend.frame_buffer[backend.frame_buffer.len() - 5..];
assert_eq!(u16::from_le_bytes([footer[0], footer[1]]) as u32, pps_min);
assert_eq!(backend.pps_clamp_count, 1);
}
#[test]
fn write_frame_ready_without_device_is_disconnected() {
let mut backend = HeliosBackend::new(0);
let points = vec![LaserPoint::new(0.0, 0.0, 65535, 0, 0, 65535)];
let err = backend.write_frame_ready(30_000, &points).unwrap_err();
assert!(err.is_disconnected());
}
#[test]
fn is_connected_is_false_without_a_device() {
let backend = HeliosBackend::new(0);
assert!(!backend.is_connected());
}
use crate::protocols::helios::native::test_support::{FakeUsb, WriteScript};
fn one_point() -> Vec<LaserPoint> {
vec![LaserPoint::new(0.0, 0.0, 65535, 0, 0, 65535)]
}
fn backend_over(fake: &FakeUsb) -> HeliosBackend {
HeliosBackend::from_dac(HeliosDac::from_endpoints(fake.clone(), Some(1)))
}
#[test]
fn is_ready_for_frame_true_when_status_ready() {
let fake = FakeUsb::default();
fake.script_int_in([Ok(vec![0x83, 1])]); let mut backend = backend_over(&fake);
assert!(backend.is_ready_for_frame());
assert_eq!(
backend.status_error_count, 0,
"no error recorded on success"
);
}
#[test]
fn is_ready_for_frame_false_when_status_not_ready() {
let fake = FakeUsb::default();
fake.script_int_in([Ok(vec![0x83, 0])]); let mut backend = backend_over(&fake);
assert!(!backend.is_ready_for_frame());
assert_eq!(backend.status_error_count, 0);
}
#[test]
fn is_ready_for_frame_nonfatal_error_counts_via_on_status_error() {
let fake = FakeUsb::default();
fake.script_int_in([Err(rusb::Error::Timeout)]); let mut backend = backend_over(&fake);
assert!(!backend.is_ready_for_frame());
assert_eq!(backend.status_error_count, 1);
assert!(!backend.fatal_disconnect);
}
#[test]
fn is_ready_for_frame_fatal_error_flags_disconnect_via_on_status_error() {
let fake = FakeUsb::default();
fake.script_int_in([Err(rusb::Error::NoDevice)]); let mut backend = backend_over(&fake);
assert!(!backend.is_ready_for_frame());
assert!(backend.fatal_disconnect);
}
#[test]
fn write_frame_ready_status_writes_frame_and_reports_written() {
let fake = FakeUsb::default();
fake.script_int_in([Ok(vec![0x83, 1])]); let mut backend = backend_over(&fake);
let outcome = backend.write_frame(30_000, &one_point()).unwrap();
assert_eq!(outcome, WriteOutcome::Written);
assert_eq!(
fake.bulk_writes().len(),
1,
"exactly one bulk-OUT frame transfer"
);
assert_eq!(fake.bulk_writes()[0].len(), 7 + 5);
}
#[test]
fn write_frame_not_ready_status_would_block_without_writing() {
let fake = FakeUsb::default();
fake.script_int_in([Ok(vec![0x83, 0])]); let mut backend = backend_over(&fake);
let outcome = backend.write_frame(30_000, &one_point()).unwrap();
assert_eq!(outcome, WriteOutcome::WouldBlock);
assert!(
fake.bulk_writes().is_empty(),
"no frame written when not ready"
);
}
#[test]
fn write_frame_fatal_bulk_error_flags_fatal_disconnect() {
let fake = FakeUsb::default();
fake.script_int_in([Ok(vec![0x83, 1])]); fake.script_bulk_out([WriteScript::Err(rusb::Error::Io)]); let mut backend = backend_over(&fake);
let err = backend.write_frame(30_000, &one_point()).unwrap_err();
assert!(err.is_disconnected());
assert!(
backend.fatal_disconnect,
"a fatal USB error during the write must flag Drop to leak the handle"
);
}
}