Skip to main content

viva_genicam/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2//! High level GenICam facade that re-exports the workspace crates and provides
3//! convenience wrappers.
4//!
5//! ```rust,no_run
6//! use viva_genicam::{gige, genapi, Camera, GenicamError};
7//! use std::time::Duration;
8//!
9//! # struct DummyTransport;
10//! # impl genapi::RegisterIo for DummyTransport {
11//! #     fn read(&self, _addr: u64, len: usize) -> Result<Vec<u8>, genapi::GenApiError> {
12//! #         Ok(vec![0; len])
13//! #     }
14//! #     fn write(&self, _addr: u64, _data: &[u8]) -> Result<(), genapi::GenApiError> {
15//! #         Ok(())
16//! #     }
17//! # }
18//! # #[allow(dead_code)]
19//! # fn load_nodemap() -> genapi::NodeMap {
20//! #     unimplemented!("replace with GenApi XML parsing")
21//! # }
22//! # #[allow(dead_code)]
23//! # async fn open_transport() -> Result<DummyTransport, GenicamError> {
24//! #     Ok(DummyTransport)
25//! # }
26//! # #[allow(dead_code)]
27//! # async fn run() -> Result<(), GenicamError> {
28//! let timeout = Duration::from_millis(500);
29//! let devices = gige::discover(timeout)
30//!     .await
31//!     .expect("discover cameras");
32//! println!("found {} cameras", devices.len());
33//! let mut camera = Camera::new(open_transport().await?, load_nodemap());
34//! camera.set("ExposureTime", "5000")?;
35//! # Ok(())
36//! # }
37//! ```
38//!
39//! ```rust,no_run
40//! # async fn events_example(
41//! #     mut camera: viva_genicam::Camera<viva_genicam::GigeRegisterIo>,
42//! # ) -> Result<(), viva_genicam::GenicamError> {
43//! use std::net::Ipv4Addr;
44//! let ids = ["FrameStart", "ExposureEnd"];
45//! let iface = Ipv4Addr::new(127, 0, 0, 1);
46//! camera.configure_events(iface, 10020, &ids).await?;
47//! let stream = camera.open_event_stream(iface, 10020).await?;
48//! let event = stream.next().await?;
49//! println!("event id=0x{:04X} payload={} bytes", event.id, event.data.len());
50//! # Ok(())
51//! # }
52//! ```
53//!
54//! ```rust,no_run
55//! # async fn action_example() -> Result<(), std::io::Error> {
56//! use viva_genicam::gige::action::{send_action, ActionParams};
57//! use std::net::SocketAddr;
58//! let params = ActionParams {
59//!     device_key: 0,
60//!     group_key: 1,
61//!     group_mask: 0xFFFF_FFFF,
62//!     scheduled_time: None,
63//! };
64//! let dest: SocketAddr = "255.255.255.255:3956".parse().unwrap();
65//! let summary = send_action(dest, &params, 200).await?;
66//! println!("acks={}", summary.acks);
67//! Ok(())
68//! # }
69//! ```
70
71pub use viva_genapi as genapi;
72pub use viva_gencp as gencp;
73pub use viva_gige as gige;
74pub use viva_pfnc as pfnc;
75pub use viva_sfnc as sfnc;
76#[cfg(feature = "u3v")]
77#[cfg_attr(docsrs, doc(cfg(feature = "u3v")))]
78pub use viva_u3v as u3v;
79
80pub mod chunks;
81pub mod events;
82pub mod frame;
83pub mod stream;
84pub mod time;
85
86use std::net::{IpAddr, Ipv4Addr};
87use std::sync::{Arc, Mutex, MutexGuard};
88use std::time::{Duration, Instant, SystemTime};
89
90use crate::events::{
91    bind_socket as bind_event_socket_internal,
92    configure_message_channel_raw as configure_message_channel_fallback,
93};
94use crate::genapi::{GenApiError, Node, NodeMap, RegisterIo, SkOutput};
95use gige::GigeDevice;
96use gige::gvcp::consts as gvcp_consts;
97use thiserror::Error;
98use tokio::time::sleep;
99use tracing::{debug, info, warn};
100
101pub use chunks::{ChunkKind, ChunkMap, ChunkValue, parse_chunk_bytes};
102pub use events::{Event, EventStream};
103pub use frame::Frame;
104pub use gige::action::{AckSummary, ActionParams};
105pub use stream::{FrameStream, Stream, StreamBuilder, StreamDest};
106#[cfg(feature = "u3v")]
107#[cfg_attr(docsrs, doc(cfg(feature = "u3v")))]
108pub use stream::{U3vFrameStream, U3vStreamBuilder};
109pub use time::TimeSync;
110
111/// Error type produced by the high level GenICam facade.
112#[derive(Debug, Error)]
113#[non_exhaustive]
114pub enum GenicamError {
115    /// Wrapper around GenApi errors produced by the nodemap.
116    #[error(transparent)]
117    GenApi(#[from] GenApiError),
118    /// Transport level failure while accessing registers.
119    #[error("transport: {0}")]
120    Transport(String),
121    /// Parsing a user supplied value failed.
122    #[error("parse error: {0}")]
123    Parse(String),
124    /// Required chunk feature missing from the nodemap.
125    #[error("chunk feature '{0}' not found; verify camera supports chunk data")]
126    MissingChunkFeature(String),
127    /// The camera reported a pixel format without a conversion path.
128    #[error("unsupported pixel format: {0}")]
129    UnsupportedPixelFormat(viva_pfnc::PixelFormat),
130}
131
132impl GenicamError {
133    fn parse<S: Into<String>>(msg: S) -> Self {
134        GenicamError::Parse(msg.into())
135    }
136
137    fn transport<S: Into<String>>(msg: S) -> Self {
138        GenicamError::Transport(msg.into())
139    }
140}
141
142/// Camera facade combining a nodemap with a transport implementing [`RegisterIo`].
143#[derive(Debug)]
144pub struct Camera<T: RegisterIo> {
145    transport: T,
146    nodemap: NodeMap,
147    time_sync: TimeSync,
148}
149
150impl<T: RegisterIo> Camera<T> {
151    /// Create a new camera wrapper from a transport and a nodemap.
152    pub fn new(transport: T, nodemap: NodeMap) -> Self {
153        Self {
154            transport,
155            nodemap,
156            time_sync: TimeSync::with_capacity(64),
157        }
158    }
159
160    #[inline]
161    fn with_map<R>(&mut self, f: impl FnOnce(&mut NodeMap, &T) -> R) -> R {
162        let transport = &self.transport;
163        let nodemap = &mut self.nodemap;
164        f(nodemap, transport)
165    }
166
167    /// Return a reference to the underlying transport.
168    pub fn transport(&self) -> &T {
169        &self.transport
170    }
171
172    /// Return a mutable reference to the underlying transport.
173    pub fn transport_mut(&mut self) -> &mut T {
174        &mut self.transport
175    }
176
177    /// Access the nodemap metadata.
178    pub fn nodemap(&self) -> &NodeMap {
179        &self.nodemap
180    }
181
182    /// Mutable access to the nodemap.
183    pub fn nodemap_mut(&mut self) -> &mut NodeMap {
184        &mut self.nodemap
185    }
186
187    /// List available entries for an enumeration feature.
188    pub fn enum_entries(&self, name: &str) -> Result<Vec<String>, GenicamError> {
189        self.nodemap.enum_entries(name).map_err(Into::into)
190    }
191
192    /// Retrieve a feature value as a string using the nodemap type to format it.
193    pub fn get(&self, name: &str) -> Result<String, GenicamError> {
194        match self.nodemap.node(name) {
195            Some(Node::Integer(_)) => {
196                Ok(self.nodemap.get_integer(name, &self.transport)?.to_string())
197            }
198            Some(Node::Float(_)) => Ok(self.nodemap.get_float(name, &self.transport)?.to_string()),
199            Some(Node::Enum(_)) => self
200                .nodemap
201                .get_enum(name, &self.transport)
202                .map_err(Into::into),
203            Some(Node::Boolean(_)) => Ok(self.nodemap.get_bool(name, &self.transport)?.to_string()),
204            Some(Node::SwissKnife(sk)) => match sk.output {
205                SkOutput::Float => Ok(self.nodemap.get_float(name, &self.transport)?.to_string()),
206                SkOutput::Integer => {
207                    Ok(self.nodemap.get_integer(name, &self.transport)?.to_string())
208                }
209            },
210            Some(Node::Converter(conv)) => match conv.output {
211                SkOutput::Float => Ok(self
212                    .nodemap
213                    .get_converter(name, &self.transport)?
214                    .to_string()),
215                SkOutput::Integer => {
216                    Ok((self.nodemap.get_converter(name, &self.transport)? as i64).to_string())
217                }
218            },
219            Some(Node::IntConverter(_)) => Ok(self
220                .nodemap
221                .get_int_converter(name, &self.transport)?
222                .to_string()),
223            Some(Node::String(_)) => self
224                .nodemap
225                .get_string(name, &self.transport)
226                .map_err(Into::into),
227            Some(Node::Command(_)) => {
228                Err(GenicamError::GenApi(GenApiError::Type(name.to_string())))
229            }
230            // A `<Register>` is a byte array with no declared interpretation.
231            // Rendering it into this `String`-typed API would mean inventing an
232            // encoding — hex? base64? — that nothing asked for and no caller
233            // could rely on. Raw access goes through
234            // `nodemap().get_register(name, transport)`.
235            Some(Node::Register(_)) => {
236                Err(GenicamError::GenApi(GenApiError::Type(name.to_string())))
237            }
238            Some(Node::Category(_)) => Ok(String::new()),
239            // `Node` is `#[non_exhaustive]`, so a node type added in
240            // `viva-genapi` no longer breaks this crate's build. Name the kind
241            // rather than returning a bare type error, so the gap is legible
242            // from a log line instead of needing a debugger.
243            Some(other) => Err(GenicamError::GenApi(GenApiError::Type(format!(
244                "{name} is a {} node, which Camera::get cannot render as a string",
245                other.kind_name()
246            )))),
247            None => Err(GenApiError::NodeNotFound(name.to_string()).into()),
248        }
249    }
250
251    /// Set a feature value using a string representation.
252    pub fn set(&mut self, name: &str, value: &str) -> Result<(), GenicamError> {
253        match self.nodemap.node(name) {
254            Some(Node::Integer(_)) => {
255                let parsed: i64 = value
256                    .parse()
257                    .map_err(|_| GenicamError::parse(format!("invalid integer for {name}")))?;
258                self.nodemap
259                    .set_integer(name, parsed, &self.transport)
260                    .map_err(Into::into)
261            }
262            Some(Node::Float(_)) => {
263                let parsed: f64 = value
264                    .parse()
265                    .map_err(|_| GenicamError::parse(format!("invalid float for {name}")))?;
266                self.nodemap
267                    .set_float(name, parsed, &self.transport)
268                    .map_err(Into::into)
269            }
270            Some(Node::Enum(_)) => self
271                .nodemap
272                .set_enum(name, value, &self.transport)
273                .map_err(Into::into),
274            Some(Node::Boolean(_)) => {
275                let parsed = parse_bool(value).ok_or_else(|| {
276                    GenicamError::parse(format!("invalid boolean for {name}: {value}"))
277                })?;
278                self.nodemap
279                    .set_bool(name, parsed, &self.transport)
280                    .map_err(Into::into)
281            }
282            Some(Node::SwissKnife(_)) => Err(GenApiError::Type(name.to_string()).into()),
283            Some(Node::Converter(_)) => {
284                // Converters are read-only from the user perspective
285                // (they transform values from underlying nodes)
286                Err(GenApiError::Type(name.to_string()).into())
287            }
288            Some(Node::IntConverter(_)) => Err(GenApiError::Type(name.to_string()).into()),
289            Some(Node::String(_)) => self
290                .nodemap
291                .set_string(name, value, &self.transport)
292                .map_err(Into::into),
293            Some(Node::Command(_)) => self
294                .nodemap
295                .exec_command(name, &self.transport)
296                .map_err(Into::into),
297            // See the matching arm in `get`: a byte array has no string form
298            // this API could parse back. Use `nodemap().set_register(...)`.
299            Some(Node::Register(_)) => Err(GenApiError::Type(name.to_string()).into()),
300            Some(Node::Category(_)) => Err(GenApiError::Type(name.to_string()).into()),
301            // See the matching arm in `get`.
302            Some(other) => Err(GenicamError::GenApi(GenApiError::Type(format!(
303                "{name} is a {} node, which Camera::set cannot write from a string",
304                other.kind_name()
305            )))),
306            None => Err(GenApiError::NodeNotFound(name.to_string()).into()),
307        }
308    }
309
310    /// Convenience wrapper for exposure time features expressed in microseconds.
311    pub fn set_exposure_time_us(&mut self, value: f64) -> Result<(), GenicamError> {
312        // Use SFNC name directly to avoid cross-crate constant lookup issues in docs
313        self.set_float_feature("ExposureTime", value)
314    }
315
316    /// Convenience wrapper for gain features expressed in decibel.
317    pub fn set_gain_db(&mut self, value: f64) -> Result<(), GenicamError> {
318        self.set_float_feature("Gain", value)
319    }
320
321    fn set_float_feature(&mut self, name: &str, value: f64) -> Result<(), GenicamError> {
322        match self.nodemap.node(name) {
323            Some(Node::Float(_)) => self
324                .nodemap
325                .set_float(name, value, &self.transport)
326                .map_err(Into::into),
327            Some(_) => Err(GenApiError::Type(name.to_string()).into()),
328            None => Err(GenApiError::NodeNotFound(name.to_string()).into()),
329        }
330    }
331
332    /// Capture device/host timestamp pairs and fit a mapping model.
333    pub async fn time_calibrate(
334        &mut self,
335        samples: usize,
336        interval_ms: u64,
337    ) -> Result<(), GenicamError> {
338        if samples < 2 {
339            return Err(GenicamError::transport(
340                "time calibration requires at least two samples",
341            ));
342        }
343
344        let cap = samples.max(self.time_sync.capacity());
345        self.time_sync = TimeSync::with_capacity(cap);
346
347        let latch_cmd = self.find_alias(viva_sfnc::TS_LATCH_CMDS);
348        let value_node = self
349            .find_alias(viva_sfnc::TS_VALUE_NODES)
350            .ok_or_else(|| GenApiError::NodeNotFound("TimestampValue".into()))?;
351
352        let mut freq_hz = if let Some(name) = self.find_alias(viva_sfnc::TS_FREQ_NODES) {
353            match self.nodemap.get_integer(name, &self.transport) {
354                Ok(value) if value > 0 => Some(value as f64),
355                Ok(_) => None,
356                Err(err) => {
357                    debug!(node = name, error = %err, "failed to read timestamp frequency");
358                    None
359                }
360            }
361        } else {
362            None
363        };
364
365        info!(samples, interval_ms, "starting time calibration");
366        let mut first_sample: Option<(u64, Instant)> = None;
367        let mut last_sample: Option<(u64, Instant)> = None;
368
369        for idx in 0..samples {
370            if let Some(cmd) = latch_cmd {
371                self.nodemap
372                    .exec_command(cmd, &self.transport)
373                    .map_err(GenicamError::from)?;
374            }
375
376            let raw_ticks = self
377                .nodemap
378                .get_integer(value_node, &self.transport)
379                .map_err(GenicamError::from)?;
380            let dev_ticks = u64::try_from(raw_ticks).map_err(|_| {
381                GenicamError::transport("timestamp value is negative; unsupported camera")
382            })?;
383            let host = Instant::now();
384            self.time_sync.update(dev_ticks, host);
385            if idx == 0 {
386                first_sample = Some((dev_ticks, host));
387            }
388            last_sample = Some((dev_ticks, host));
389            if let Some(origin) = self.time_sync.origin_instant() {
390                let ns = host.duration_since(origin).as_nanos();
391                debug!(
392                    sample = idx,
393                    ticks = dev_ticks,
394                    host_ns = ns,
395                    "timestamp sample"
396                );
397            } else {
398                debug!(sample = idx, ticks = dev_ticks, "timestamp sample");
399            }
400
401            if interval_ms > 0 && idx + 1 < samples {
402                sleep(Duration::from_millis(interval_ms)).await;
403            }
404        }
405
406        if freq_hz.is_none()
407            && let (Some((first_ticks, first_host)), Some((last_ticks, last_host))) =
408                (first_sample, last_sample)
409            && last_ticks > first_ticks
410            && let Some(delta) = last_host.checked_duration_since(first_host)
411        {
412            let secs = delta.as_secs_f64();
413            if secs > 0.0 {
414                freq_hz = Some((last_ticks - first_ticks) as f64 / secs);
415            }
416        }
417
418        let (a, b) = self
419            .time_sync
420            .fit(freq_hz)
421            .ok_or_else(|| GenicamError::transport("insufficient samples for timestamp fit"))?;
422
423        if let Some(freq) = freq_hz {
424            info!(freq_hz = freq, a, b, "time calibration complete");
425        } else {
426            info!(a, b, "time calibration complete");
427        }
428
429        Ok(())
430    }
431
432    /// Map device tick counters to host time using the fitted model.
433    pub fn map_dev_ts(&self, dev_ticks: u64) -> SystemTime {
434        self.time_sync.to_host_time(dev_ticks)
435    }
436
437    /// Inspect the timestamp synchroniser state.
438    pub fn time_sync(&self) -> &TimeSync {
439        &self.time_sync
440    }
441
442    /// Reset the device timestamp counter when supported by the camera.
443    pub fn time_reset(&mut self) -> Result<(), GenicamError> {
444        if let Some(cmd) = self.find_alias(viva_sfnc::TS_RESET_CMDS) {
445            self.nodemap
446                .exec_command(cmd, &self.transport)
447                .map_err(GenicamError::from)?;
448            self.time_sync = TimeSync::with_capacity(self.time_sync.capacity());
449            info!(command = cmd, "timestamp counter reset");
450        }
451        Ok(())
452    }
453
454    /// Execute a command feature by name.
455    ///
456    /// [`Camera::set`] already dispatches to this for `Node::Command`, but
457    /// only from a `&str` value; callers that hold a command name directly
458    /// would otherwise have to reach through [`Camera::nodemap_mut`] and
459    /// [`Camera::transport`] at once, which the borrow checker rejects from
460    /// outside the struct. Splitting the two fields here is the same thing
461    /// [`Camera::acquisition_start`] does.
462    pub fn execute_command(&mut self, name: &str) -> Result<(), GenicamError> {
463        self.nodemap
464            .exec_command(name, &self.transport)
465            .map_err(Into::into)
466    }
467
468    /// Trigger acquisition start via the SFNC command feature.
469    pub fn acquisition_start(&mut self) -> Result<(), GenicamError> {
470        self.nodemap
471            .exec_command("AcquisitionStart", &self.transport)
472            .map_err(Into::into)
473    }
474
475    /// Trigger acquisition stop via the SFNC command feature.
476    pub fn acquisition_stop(&mut self) -> Result<(), GenicamError> {
477        self.nodemap
478            .exec_command("AcquisitionStop", &self.transport)
479            .map_err(Into::into)
480    }
481
482    /// Configure chunk mode and enable the requested selectors.
483    pub fn configure_chunks(&mut self, cfg: &ChunkConfig) -> Result<(), GenicamError> {
484        self.ensure_chunk_feature(viva_sfnc::CHUNK_MODE_ACTIVE)?;
485        self.ensure_chunk_feature(viva_sfnc::CHUNK_SELECTOR)?;
486        self.ensure_chunk_feature(viva_sfnc::CHUNK_ENABLE)?;
487
488        // SAFE: split-borrow distinct fields of `self`
489        self.with_map(|nm, tr| {
490            nm.set_bool(viva_sfnc::CHUNK_MODE_ACTIVE, cfg.active, tr)?;
491            for s in &cfg.selectors {
492                nm.set_enum(viva_sfnc::CHUNK_SELECTOR, s, tr)?;
493                nm.set_bool(viva_sfnc::CHUNK_ENABLE, cfg.active, tr)?;
494            }
495            Ok(())
496        })
497    }
498
499    /// Configure the GVCP message channel and enable delivery of the requested events.
500    pub async fn configure_events(
501        &mut self,
502        local_ip: Ipv4Addr,
503        port: u16,
504        enable_ids: &[&str],
505    ) -> Result<(), GenicamError> {
506        info!(%local_ip, port, "configuring GVCP events");
507        // Pre-compute aliases before taking a mutable borrow of the nodemap
508        let msg_sel = self.find_alias(viva_sfnc::MSG_SEL);
509        let msg_ip = self.find_alias(viva_sfnc::MSG_IP);
510        let msg_port = self.find_alias(viva_sfnc::MSG_PORT);
511        let msg_en = self.find_alias(viva_sfnc::MSG_EN);
512
513        let channel_configured = self.with_map(|nodemap, transport| {
514            let mut ok = true;
515
516            if let Some(selector) = msg_sel {
517                match nodemap.enum_entries(selector) {
518                    Ok(entries) => {
519                        if let Some(entry) = entries.into_iter().next() {
520                            if let Err(err) = nodemap.set_enum(selector, &entry, transport) {
521                                warn!(node = selector, error = %err, "failed to set message selector");
522                                ok = false;
523                            }
524                        } else {
525                            warn!(node = selector, "message selector missing entries");
526                            ok = false;
527                        }
528                    }
529                    Err(err) => {
530                        warn!(feature = selector, error = %err, "failed to query message selector");
531                        ok = false;
532                    }
533                }
534            } else {
535                ok = false;
536            }
537
538            if let Some(node) = msg_ip {
539                let value = u32::from(local_ip) as i64;
540                if let Err(err) = nodemap.set_integer(node, value, transport) {
541                    warn!(feature = node, error = %err, "failed to write message IP");
542                    ok = false;
543                }
544            } else {
545                ok = false;
546            }
547
548            if let Some(node) = msg_port {
549                if let Err(err) = nodemap.set_integer(node, port as i64, transport) {
550                    warn!(feature = node, error = %err, "failed to write message port");
551                    ok = false;
552                }
553            } else {
554                ok = false;
555            }
556
557            if let Some(node) = msg_en {
558                if let Err(err) = nodemap.set_bool(node, true, transport) {
559                    warn!(feature = node, error = %err, "failed to enable message channel");
560                    ok = false;
561                }
562            } else {
563                ok = false;
564            }
565
566            ok
567        });
568
569        if !channel_configured {
570            configure_message_channel_fallback(&self.transport, local_ip, port)?;
571        }
572
573        let mut used_sfnc = self.nodemap.node(viva_sfnc::EVENT_SELECTOR).is_some()
574            && self.nodemap.node(viva_sfnc::EVENT_NOTIFICATION).is_some();
575
576        used_sfnc = self.with_map(|nodemap, transport| {
577            if !used_sfnc {
578                return false;
579            }
580            for &name in enable_ids {
581                if let Err(err) = nodemap.set_enum(viva_sfnc::EVENT_SELECTOR, name, transport) {
582                    warn!(event = name, error = %err, "failed to select event via SFNC");
583                    return false;
584                }
585                if let Err(err) = nodemap.set_enum(
586                    viva_sfnc::EVENT_NOTIFICATION,
587                    viva_sfnc::EVENT_NOTIF_ON,
588                    transport,
589                ) {
590                    warn!(event = name, error = %err, "failed to enable event via SFNC");
591                    return false;
592                }
593            }
594            true
595        });
596
597        if !used_sfnc && !enable_ids.is_empty() {
598            // The message channel is addressable through bootstrap registers,
599            // but which events a device emits is a GenApi decision with no
600            // bootstrap equivalent. Say so rather than writing somewhere
601            // hopeful.
602            return Err(GenicamError::transport(format!(
603                "cannot enable events {enable_ids:?}: camera exposes neither \
604                 `{}` nor `{}`, and event selection has no bootstrap register",
605                viva_sfnc::EVENT_SELECTOR,
606                viva_sfnc::EVENT_NOTIFICATION,
607            )));
608        }
609
610        Ok(())
611    }
612
613    /// Configure the stream channel for multicast delivery.
614    pub fn configure_stream_multicast(
615        &mut self,
616        stream_idx: u32,
617        group: Ipv4Addr,
618        port: u16,
619    ) -> Result<(), GenicamError> {
620        if (group.octets()[0] & 0xF0) != 0xE0 {
621            return Err(GenicamError::transport(
622                "multicast group must be within 224.0.0.0/4",
623            ));
624        }
625        info!(stream_idx, %group, port, "configuring multicast stream");
626
627        // Precompute node names before taking &mut self.nodemap
628        let dest_addr_node = self.find_alias(viva_sfnc::SCP_DEST_ADDR);
629        let host_port_node = self.find_alias(viva_sfnc::SCP_HOST_PORT);
630        let mcast_en_node = self.find_alias(viva_sfnc::MULTICAST_ENABLE);
631
632        let mut used_sfnc = true;
633        self.with_map(|nm, tr| {
634            if nm.node(viva_sfnc::STREAM_CH_SELECTOR).is_some() {
635                if let Err(err) =
636                    nm.set_integer(viva_sfnc::STREAM_CH_SELECTOR, stream_idx as i64, tr)
637                {
638                    warn!(
639                        channel = stream_idx,
640                        error = %err,
641                        "failed to select stream channel via SFNC"
642                    );
643                    used_sfnc = false;
644                }
645            } else {
646                used_sfnc = false;
647            }
648
649            if let Some(node) = dest_addr_node {
650                if let Err(err) = nm.set_integer(node, u32::from(group) as i64, tr) {
651                    warn!(feature = node, error = %err, "failed to write multicast address");
652                    used_sfnc = false;
653                }
654            } else {
655                used_sfnc = false;
656            }
657
658            if let Some(node) = host_port_node {
659                if let Err(err) = nm.set_integer(node, port as i64, tr) {
660                    warn!(feature = node, error = %err, "failed to write multicast port");
661                    used_sfnc = false;
662                }
663            } else {
664                used_sfnc = false;
665            }
666
667            if let Some(node) = mcast_en_node {
668                let _ = nm.set_bool(node, true, tr);
669            }
670        });
671
672        if !used_sfnc {
673            let base = gvcp_consts::STREAM_CHANNEL_BASE
674                + stream_idx as u64 * gvcp_consts::STREAM_CHANNEL_STRIDE;
675            let addr_reg = base + gvcp_consts::STREAM_DESTINATION_ADDRESS;
676            self.transport
677                .write(addr_reg, &group.octets())
678                .map_err(|err| GenicamError::transport(format!("write multicast addr: {err}")))?;
679            let port_reg = base + gvcp_consts::STREAM_DESTINATION_PORT;
680            self.transport
681                .write(port_reg, &port.to_be_bytes())
682                .map_err(|err| GenicamError::transport(format!("write multicast port: {err}")))?;
683            info!(
684                stream_idx,
685                %group,
686                port,
687                "configured multicast destination via raw registers"
688            );
689        } else {
690            info!(
691                stream_idx,
692                %group,
693                port,
694                "configured multicast destination via SFNC"
695            );
696        }
697
698        Ok(())
699    }
700
701    /// Open a GVCP event stream bound to the provided local endpoint.
702    pub async fn open_event_stream(
703        &self,
704        local_ip: Ipv4Addr,
705        port: u16,
706    ) -> Result<EventStream, GenicamError> {
707        let socket = bind_event_socket_internal(IpAddr::V4(local_ip), port).await?;
708        let time_sync = if !self.time_sync.is_empty() {
709            Some(Arc::new(self.time_sync.clone()))
710        } else {
711            None
712        };
713        Ok(EventStream::new(socket, time_sync))
714    }
715
716    fn ensure_chunk_feature(&self, name: &str) -> Result<(), GenicamError> {
717        if self.nodemap.node(name).is_none() {
718            return Err(GenicamError::MissingChunkFeature(name.to_string()));
719        }
720        Ok(())
721    }
722
723    fn find_alias(&self, names: &[&'static str]) -> Option<&'static str> {
724        names
725            .iter()
726            .copied()
727            .find(|name| self.nodemap.node(name).is_some())
728    }
729}
730
731/// Configuration for enabling chunk data via SFNC features.
732#[derive(Debug, Clone, Default)]
733pub struct ChunkConfig {
734    /// Names of chunk selectors that should be enabled on the device.
735    pub selectors: Vec<String>,
736    /// Whether chunk mode should be active after configuration.
737    pub active: bool,
738}
739
740/// Blocking adapter turning an asynchronous [`GigeDevice`] into a [`RegisterIo`]
741/// implementation.
742///
743/// The adapter uses [`tokio::runtime::Handle::block_on`] to synchronously wait
744/// on GVCP register transactions.  When called from within a tokio runtime
745/// context it automatically wraps the call in [`tokio::task::block_in_place`]
746/// so the executor can keep making progress.  This makes it safe to call from
747/// both async and plain synchronous contexts.
748///
749/// **Note:** `block_in_place` requires a multi-thread runtime.  Using a
750/// `current_thread` runtime will still panic.
751///
752/// # Control channel keepalive
753///
754/// Constructing the adapter spawns a background task that keeps the device's
755/// control channel alive; see [`GigeRegisterIo::new`]. Holding a
756/// `GigeRegisterIo` is therefore enough to keep control privilege — no caller
757/// has to run a heartbeat of its own.
758pub struct GigeRegisterIo {
759    handle: tokio::runtime::Handle,
760    device: Arc<Mutex<GigeDevice>>,
761}
762
763impl GigeRegisterIo {
764    /// Create a new adapter using the provided runtime handle and device.
765    ///
766    /// This also spawns the control-channel keepalive on `handle`. A GigE Vision
767    /// device revokes control privilege if it receives no GVCP command within
768    /// `GevHeartbeatTimeout` (3 000 ms on both `viva-fake-gige` and the aravis
769    /// fake camera, `arvfakecamera.c:1082`), and GVSP image traffic does not
770    /// count — so a camera can sit at an interactive prompt, or stream frames for
771    /// a minute, and then refuse the next write with `ACCESS_DENIED`.
772    ///
773    /// Nothing in an application's normal flow refreshes that timer, which is why
774    /// three consumers of this crate had each grown their own copy of the loop —
775    /// and why the Python bindings, which had none, hit the failure.
776    ///
777    /// The keepalive stops when the adapter is dropped, so privilege is
778    /// maintained for exactly as long as the transport that needs it.
779    pub fn new(handle: tokio::runtime::Handle, device: GigeDevice) -> Self {
780        let device = Arc::new(Mutex::new(device));
781        spawn_keepalive(handle.clone(), Arc::downgrade(&device));
782        Self { handle, device }
783    }
784
785    /// Lock the underlying [`GigeDevice`] for direct async operations.
786    ///
787    /// This is intended for callers that need the raw device (e.g. stream
788    /// channel configuration) while the `Camera` wrapper holds the transport.
789    pub fn lock_device(&self) -> Result<MutexGuard<'_, GigeDevice>, GenicamError> {
790        self.device
791            .lock()
792            .map_err(|_| GenicamError::transport("gige device mutex poisoned"))
793    }
794
795    fn lock(&self) -> Result<MutexGuard<'_, GigeDevice>, GenApiError> {
796        self.device
797            .lock()
798            .map_err(|_| GenApiError::Io("gige device mutex poisoned".into()))
799    }
800}
801
802impl RegisterIo for GigeRegisterIo {
803    fn read(&self, addr: u64, len: usize) -> Result<Vec<u8>, GenApiError> {
804        let mut device = self.lock()?;
805        let fut = device.read_mem(addr, len);
806        if tokio::runtime::Handle::try_current().is_ok() {
807            tokio::task::block_in_place(|| self.handle.block_on(fut))
808        } else {
809            self.handle.block_on(fut)
810        }
811        .map_err(|err| GenApiError::Io(err.to_string()))
812    }
813
814    fn write(&self, addr: u64, data: &[u8]) -> Result<(), GenApiError> {
815        let mut device = self.lock()?;
816        let fut = device.write_mem(addr, data);
817        if tokio::runtime::Handle::try_current().is_ok() {
818            tokio::task::block_in_place(|| self.handle.block_on(fut))
819        } else {
820            self.handle.block_on(fut)
821        }
822        .map_err(|err| GenApiError::Io(err.to_string()))
823    }
824}
825
826// ---------------------------------------------------------------------------
827// Control channel keepalive
828// ---------------------------------------------------------------------------
829
830/// Heartbeat window assumed when the device does not report a usable one.
831///
832/// Both `viva-fake-gige` and the aravis fake camera report 3 000 ms, and it is
833/// short enough to be a safe assumption for a device that tells us nothing.
834const ASSUMED_HEARTBEAT_TIMEOUT_MS: u32 = 3_000;
835
836/// How many keepalives we aim to fit inside one heartbeat window.
837///
838/// Four means three consecutive pings can be lost — to packet loss, to a long
839/// `lock_device()` hold during stream setup, to a scheduling stall — and the
840/// fourth still lands inside the window.
841const KEEPALIVES_PER_WINDOW: u32 = 4;
842
843/// Floor on the keepalive period, so a device reporting an implausibly small
844/// timeout cannot turn the keepalive into a flood.
845const MIN_KEEPALIVE_PERIOD: Duration = Duration::from_millis(100);
846
847/// Ceiling on the keepalive period. A device may report a very long window; the
848/// keepalive is also our liveness check, so it stays reasonably prompt.
849const MAX_KEEPALIVE_PERIOD: Duration = Duration::from_secs(2);
850
851/// Choose a keepalive period from the device's reported `GevHeartbeatTimeout`.
852///
853/// `None` means the register could not be read; `Some(0)` is a device declining
854/// to say. Both fall back to [`ASSUMED_HEARTBEAT_TIMEOUT_MS`] rather than to a
855/// period derived from a value we do not trust.
856///
857/// aravis instead pings on a fixed 1 s period
858/// (`ARV_GV_DEVICE_HEARTBEAT_PERIOD_US`), which is fine for the 3 000 ms window
859/// devices usually report but too slow for one that asks for less. Deriving the
860/// period costs a single extra register read at connect.
861fn keepalive_period(reported_timeout_ms: Option<u32>) -> Duration {
862    let timeout_ms = match reported_timeout_ms {
863        Some(ms) if ms > 0 => ms,
864        _ => ASSUMED_HEARTBEAT_TIMEOUT_MS,
865    };
866    Duration::from_millis(u64::from(timeout_ms / KEEPALIVES_PER_WINDOW))
867        .clamp(MIN_KEEPALIVE_PERIOD, MAX_KEEPALIVE_PERIOD)
868}
869
870/// Run one GVCP transaction against the shared device from the blocking pool.
871///
872/// Returns `None` once the transport has been dropped — the keepalive's exit
873/// signal. `spawn_blocking` rather than `block_in_place` because the device
874/// mutex can be held for the whole of stream negotiation, and waiting for it on
875/// the blocking pool costs the runtime a pool thread rather than a worker.
876async fn on_blocking_pool<R, F>(
877    handle: &tokio::runtime::Handle,
878    device: &std::sync::Weak<Mutex<GigeDevice>>,
879    op: F,
880) -> Option<Result<R, String>>
881where
882    F: FnOnce(&tokio::runtime::Handle, &mut GigeDevice) -> Result<R, String> + Send + 'static,
883    R: Send + 'static,
884{
885    let device = device.upgrade()?;
886    let handle = handle.clone();
887    let joined = tokio::task::spawn_blocking(move || match device.lock() {
888        Ok(mut guard) => op(&handle, &mut guard),
889        Err(_) => Err("gige device mutex poisoned".to_string()),
890    })
891    .await;
892    Some(joined.unwrap_or_else(|err| Err(err.to_string())))
893}
894
895/// Keep the device's control channel alive for as long as the transport exists.
896///
897/// The task holds a [`Weak`](std::sync::Weak) reference to the device, so it
898/// stops on its own once the [`GigeRegisterIo`] is dropped; there is no handle
899/// to remember to shut down, and a replaced connection's keepalive retires with
900/// the connection it belonged to.
901///
902/// It contends only for the device mutex — never for whatever lock the
903/// application puts around its [`Camera`] — which is what lets it run without
904/// the pause/resume dance every app-layer copy of this loop needed.
905fn spawn_keepalive(handle: tokio::runtime::Handle, device: std::sync::Weak<Mutex<GigeDevice>>) {
906    handle.clone().spawn(async move {
907        let reported = on_blocking_pool(&handle, &device, |handle, dev| {
908            handle
909                .block_on(dev.heartbeat_timeout_ms())
910                .map_err(|err| err.to_string())
911        })
912        .await;
913
914        let period = match reported {
915            // Transport already gone; nothing to keep alive.
916            None => return,
917            Some(Ok(timeout_ms)) => {
918                debug!(timeout_ms, "device reported GevHeartbeatTimeout");
919                keepalive_period(Some(timeout_ms))
920            }
921            Some(Err(error)) => {
922                warn!(
923                    %error,
924                    assumed_timeout_ms = ASSUMED_HEARTBEAT_TIMEOUT_MS,
925                    "could not read GevHeartbeatTimeout; assuming a short window"
926                );
927                keepalive_period(None)
928            }
929        };
930        debug!(?period, "control channel keepalive started");
931
932        let mut ticker = tokio::time::interval(period);
933        // After a long device-mutex hold (stream negotiation) one fresh ping is
934        // enough; do not burst-fire the ticks that elapsed meanwhile.
935        ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
936        // `interval` fires immediately, and the timeout read above has just
937        // refreshed the timer.
938        ticker.tick().await;
939
940        let mut consecutive_failures: u32 = 0;
941        let mut privilege_seen = false;
942        loop {
943            ticker.tick().await;
944            let pinged = on_blocking_pool(&handle, &device, |handle, dev| {
945                handle
946                    .block_on(dev.ping_control_channel())
947                    .map_err(|err| err.to_string())
948            })
949            .await;
950            match pinged {
951                None => {
952                    debug!("control channel keepalive stopped: transport dropped");
953                    return;
954                }
955                Some(Ok(true)) => {
956                    if consecutive_failures > 0 {
957                        info!(consecutive_failures, "control channel keepalive recovered");
958                    }
959                    consecutive_failures = 0;
960                    privilege_seen = true;
961                }
962                Some(Ok(false)) if !privilege_seen => {
963                    // The caller never claimed control. A read-only session is
964                    // legitimate — `viva-camctl xml` is one — and there is no
965                    // privilege to keep alive, so stop rather than warn about it
966                    // every period.
967                    debug!("no control privilege held; keepalive not needed");
968                    return;
969                }
970                Some(Ok(false)) => {
971                    consecutive_failures += 1;
972                    warn!(
973                        consecutive_failures,
974                        "control channel privilege lost; either another application took \
975                         control or an earlier keepalive did not reach the device"
976                    );
977                }
978                Some(Err(error)) => {
979                    consecutive_failures += 1;
980                    warn!(%error, consecutive_failures, "control channel keepalive failed");
981                }
982            }
983        }
984    });
985}
986
987/// Connect to a GigE Vision camera and return a fully configured [`Camera`].
988///
989/// This convenience function handles all connection boilerplate:
990/// 1. Opens a GVCP control connection to the device
991/// 2. Fetches and parses the GenApi XML from the camera
992/// 3. Builds the nodemap
993/// 4. Creates the transport adapter
994///
995/// # Example
996///
997/// ```rust,ignore
998/// use std::time::Duration;
999/// use viva_genicam::{gige, connect_gige};
1000///
1001/// let devices = gige::discover(Duration::from_millis(500)).await?;
1002/// let device = devices.into_iter().next().expect("no camera found");
1003/// let mut camera = connect_gige(&device).await?;
1004/// camera.set("ExposureTime", "5000")?;
1005/// ```
1006pub async fn connect_gige(
1007    device: &gige::DeviceInfo,
1008) -> Result<Camera<GigeRegisterIo>, GenicamError> {
1009    let (camera, _xml) = connect_gige_with_xml(device).await?;
1010    Ok(camera)
1011}
1012
1013/// Connect to a GigE Vision camera and return both a [`Camera`] and the raw
1014/// GenICam XML string fetched from the device.
1015///
1016/// This is useful when the caller needs the XML for purposes beyond node
1017/// evaluation (e.g. forwarding it over a network API).
1018pub async fn connect_gige_with_xml(
1019    device: &gige::DeviceInfo,
1020) -> Result<(Camera<GigeRegisterIo>, String), GenicamError> {
1021    use std::net::{IpAddr, SocketAddr};
1022    use std::sync::Arc;
1023    use tokio::sync::Mutex as AsyncMutex;
1024
1025    let control_addr = SocketAddr::new(IpAddr::V4(device.ip), gige::GVCP_PORT);
1026    info!(%control_addr, "connecting to GigE Vision camera");
1027
1028    let mut device = gige::GigeDevice::open(control_addr)
1029        .await
1030        .map_err(|e| GenicamError::transport(e.to_string()))?;
1031
1032    // Claim control privilege (required before configuration and streaming).
1033    device
1034        .claim_control()
1035        .await
1036        .map_err(|e| GenicamError::transport(e.to_string()))?;
1037
1038    let control = Arc::new(AsyncMutex::new(device));
1039
1040    // Fetch and parse the GenApi XML.
1041    let xml = viva_genapi_xml::fetch_and_load_xml({
1042        let control = control.clone();
1043        move |address, length| {
1044            let control = control.clone();
1045            async move {
1046                let mut dev = control.lock().await;
1047                dev.read_mem(address, length)
1048                    .await
1049                    .map_err(|err| viva_genapi_xml::XmlError::Transport(err.to_string()))
1050            }
1051        }
1052    })
1053    .await
1054    .map_err(|e| GenicamError::transport(e.to_string()))?;
1055
1056    let nodemap = build_nodemap(&xml)?;
1057
1058    // Extract the device and create the blocking adapter.
1059    let handle = tokio::runtime::Handle::current();
1060    let control_device = Arc::try_unwrap(control)
1061        .map_err(|_| GenicamError::transport("control connection still in use"))?
1062        .into_inner();
1063    let transport = GigeRegisterIo::new(handle, control_device);
1064
1065    info!("GigE camera connected successfully");
1066    Ok((Camera::new(transport, nodemap), xml))
1067}
1068
1069// ---------------------------------------------------------------------------
1070// USB3 Vision transport (behind `u3v` feature)
1071// ---------------------------------------------------------------------------
1072
1073/// Blocking [`RegisterIo`] adapter wrapping a [`U3vDevice`](u3v::device::U3vDevice).
1074///
1075/// Generic over `T: UsbTransfer` so that real hardware (`RusbTransfer`) and
1076/// test doubles (`MockUsbTransfer`, `FakeU3vTransport`) all work through the
1077/// same code path. USB operations are inherently synchronous, so this adapter
1078/// simply forwards calls through a `Mutex` for thread safety.
1079#[cfg(feature = "u3v")]
1080#[cfg_attr(docsrs, doc(cfg(feature = "u3v")))]
1081pub struct U3vRegisterIo<T: u3v::usb::UsbTransfer + 'static> {
1082    device: Mutex<u3v::device::U3vDevice<T>>,
1083}
1084
1085#[cfg(feature = "u3v")]
1086impl<T: u3v::usb::UsbTransfer + 'static> U3vRegisterIo<T> {
1087    /// Create a new adapter wrapping a [`U3vDevice`](u3v::device::U3vDevice).
1088    pub fn new(device: u3v::device::U3vDevice<T>) -> Self {
1089        Self {
1090            device: Mutex::new(device),
1091        }
1092    }
1093
1094    /// Lock the underlying device for direct access (e.g. stream configuration).
1095    pub fn lock_device(&self) -> Result<MutexGuard<'_, u3v::device::U3vDevice<T>>, GenicamError> {
1096        self.device
1097            .lock()
1098            .map_err(|_| GenicamError::transport("u3v device mutex poisoned"))
1099    }
1100
1101    fn lock(&self) -> Result<MutexGuard<'_, u3v::device::U3vDevice<T>>, GenApiError> {
1102        self.device
1103            .lock()
1104            .map_err(|_| GenApiError::Io("u3v device mutex poisoned".into()))
1105    }
1106}
1107
1108#[cfg(feature = "u3v")]
1109impl<T: u3v::usb::UsbTransfer + 'static> RegisterIo for U3vRegisterIo<T> {
1110    fn read(&self, addr: u64, len: usize) -> Result<Vec<u8>, GenApiError> {
1111        let mut device = self.lock()?;
1112        device
1113            .read_mem(addr, len)
1114            .map_err(|e| GenApiError::Io(e.to_string()))
1115    }
1116
1117    fn write(&self, addr: u64, data: &[u8]) -> Result<(), GenApiError> {
1118        let mut device = self.lock()?;
1119        device
1120            .write_mem(addr, data)
1121            .map_err(|e| GenApiError::Io(e.to_string()))
1122    }
1123}
1124
1125/// Connect to a USB3 Vision camera and return a fully configured [`Camera`].
1126///
1127/// This convenience function handles all connection boilerplate:
1128/// 1. Opens the USB device and claims U3V interfaces
1129/// 2. Reads ABRM/SBRM bootstrap registers
1130/// 3. Fetches and parses the GenApi XML from the manifest table
1131/// 4. Builds the nodemap and creates the transport adapter
1132///
1133/// # Example
1134///
1135/// ```rust,ignore
1136/// use viva_genicam::{u3v, connect_u3v};
1137///
1138/// let devices = u3v::discovery::discover()?;
1139/// let device = devices.into_iter().next().expect("no U3V camera found");
1140/// let mut camera = connect_u3v(&device)?;
1141/// camera.set("ExposureTime", "5000")?;
1142/// ```
1143#[cfg(feature = "u3v-usb")]
1144#[cfg_attr(docsrs, doc(cfg(feature = "u3v-usb")))]
1145pub fn connect_u3v(
1146    device: &u3v::discovery::U3vDeviceInfo,
1147) -> Result<Camera<U3vRegisterIo<u3v::usb::RusbTransfer>>, GenicamError> {
1148    let (camera, _xml) = connect_u3v_with_xml(device)?;
1149    Ok(camera)
1150}
1151
1152/// Connect to a USB3 Vision camera and return both a [`Camera`] and the raw
1153/// GenICam XML string fetched from the device.
1154#[cfg(feature = "u3v-usb")]
1155#[cfg_attr(docsrs, doc(cfg(feature = "u3v-usb")))]
1156pub fn connect_u3v_with_xml(
1157    device_info: &u3v::discovery::U3vDeviceInfo,
1158) -> Result<(Camera<U3vRegisterIo<u3v::usb::RusbTransfer>>, String), GenicamError> {
1159    info!(
1160        vendor_id = device_info.vendor_id,
1161        product_id = device_info.product_id,
1162        "connecting to USB3 Vision camera"
1163    );
1164
1165    let mut device = u3v::device::U3vDevice::open_device(device_info)
1166        .map_err(|e| GenicamError::transport(e.to_string()))?;
1167
1168    let xml = device
1169        .fetch_xml()
1170        .map_err(|e| GenicamError::transport(e.to_string()))?;
1171
1172    let nodemap = build_nodemap(&xml)?;
1173    let transport = U3vRegisterIo::new(device);
1174
1175    info!("USB3 Vision camera connected successfully");
1176    Ok((Camera::new(transport, nodemap), xml))
1177}
1178
1179/// Create a [`Camera`] from an already-opened [`U3vDevice`](u3v::device::U3vDevice)
1180/// with any [`UsbTransfer`](u3v::usb::UsbTransfer) backend.
1181///
1182/// This is the generic entry point for testing with fake or mock transports.
1183/// The device must have been opened and bootstrapped (ABRM/SBRM read)
1184/// before calling this function.
1185#[cfg(feature = "u3v")]
1186#[cfg_attr(docsrs, doc(cfg(feature = "u3v")))]
1187pub fn open_u3v_device<T: u3v::usb::UsbTransfer + 'static>(
1188    mut device: u3v::device::U3vDevice<T>,
1189) -> Result<(Camera<U3vRegisterIo<T>>, String), GenicamError> {
1190    let xml = device
1191        .fetch_xml()
1192        .map_err(|e| GenicamError::transport(e.to_string()))?;
1193    let nodemap = build_nodemap(&xml)?;
1194    let transport = U3vRegisterIo::new(device);
1195    Ok((Camera::new(transport, nodemap), xml))
1196}
1197
1198/// Parse a camera's GenApi XML and build its nodemap, reporting what we could
1199/// not represent.
1200///
1201/// Both layers isolate per-node failures, so a camera opens with a missing
1202/// feature rather than not at all. That only helps if the loss is visible:
1203/// every dropped node is logged here, and a summary line goes out at `warn`
1204/// so a user hitting an unsupported construct has something to report.
1205fn build_nodemap(xml: &str) -> Result<NodeMap, GenicamError> {
1206    let model =
1207        viva_genapi_xml::parse(xml).map_err(|e| GenicamError::parse(format!("GenApi XML: {e}")))?;
1208    // The nodemap absorbs the XML layer's losses, so one list covers both.
1209    let nodemap = NodeMap::try_from_xml(model)
1210        .map_err(|e| GenicamError::parse(format!("GenApi model: {e}")))?;
1211    for skipped in nodemap.skipped() {
1212        debug!(
1213            kind = %skipped.tag,
1214            node = skipped.name.as_deref().unwrap_or("<unnamed>"),
1215            error = %skipped.error,
1216            "GenApi node unavailable"
1217        );
1218    }
1219
1220    if !nodemap.skipped().is_empty() {
1221        warn!(
1222            unavailable = nodemap.skipped().len(),
1223            features = nodemap.node_names().count(),
1224            "some camera features are unavailable; run `viva-camctl report` for the list \
1225             and please report them at https://github.com/VitalyVorobyev/viva-genicam/issues"
1226        );
1227    }
1228    Ok(nodemap)
1229}
1230
1231fn parse_bool(value: &str) -> Option<bool> {
1232    match value.trim().to_ascii_lowercase().as_str() {
1233        "1" | "true" => Some(true),
1234        "0" | "false" => Some(false),
1235        _ => None,
1236    }
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241    use super::*;
1242
1243    #[test]
1244    fn keepalive_period_fits_four_pings_in_the_reported_window() {
1245        assert_eq!(keepalive_period(Some(3_000)), Duration::from_millis(750));
1246        assert_eq!(keepalive_period(Some(1_000)), Duration::from_millis(250));
1247    }
1248
1249    #[test]
1250    fn an_unusable_heartbeat_timeout_falls_back_to_a_short_window() {
1251        // A device that declines to report, and a register we could not read,
1252        // must not be believed as "0 ms" or "no timeout at all".
1253        let assumed = keepalive_period(Some(ASSUMED_HEARTBEAT_TIMEOUT_MS));
1254        assert_eq!(keepalive_period(None), assumed);
1255        assert_eq!(keepalive_period(Some(0)), assumed);
1256    }
1257
1258    #[test]
1259    fn the_period_is_clamped_at_both_ends() {
1260        // 40 ms / 4 = 10 ms would be a flood; a 10-minute window would make the
1261        // keepalive useless as a liveness check.
1262        assert_eq!(keepalive_period(Some(40)), MIN_KEEPALIVE_PERIOD);
1263        assert_eq!(keepalive_period(Some(600_000)), MAX_KEEPALIVE_PERIOD);
1264    }
1265}