Skip to main content

viva_camctl/
common.rs

1use std::fs::File;
2use std::io::Write;
3use std::net::{IpAddr, Ipv4Addr, SocketAddr};
4use std::path::PathBuf;
5use std::sync::Arc;
6use std::time::{Duration, SystemTime};
7
8use anyhow::{Context, Result, anyhow, bail};
9use serde::Serialize;
10use std::convert::TryInto;
11use time::OffsetDateTime;
12use time::format_description::well_known::Rfc3339;
13use tokio::runtime::Handle;
14use tokio::sync::Mutex;
15use viva_genapi_xml::{self, XmlError};
16use viva_genicam::genapi::NodeMap;
17use viva_genicam::{Camera, GigeRegisterIo};
18use viva_gige::DeviceInfo;
19use viva_gige::discover_on_interface;
20use viva_gige::gvcp::GigeDevice;
21use viva_gige::nic::{Iface, IfaceSelector};
22use viva_gige::{GVCP_PORT, discover};
23
24pub const DEFAULT_DISCOVERY_TIMEOUT_MS: u64 = 500;
25
26pub fn format_mac(mac: &[u8; 6]) -> String {
27    mac.iter()
28        .map(|b| format!("{b:02X}"))
29        .collect::<Vec<_>>()
30        .join(":")
31}
32
33pub async fn discover_devices(
34    timeout: Duration,
35    iface: Option<&IfaceSelector>,
36) -> Result<Vec<DeviceInfo>> {
37    let devices = if let Some(selector) = iface {
38        let iface = resolve_iface_selector(selector)?;
39        discover_on_interface(timeout, iface.name())
40            .await
41            .context("discover devices on interface")?
42    } else {
43        discover(timeout).await.context("broadcast discovery")?
44    };
45    Ok(devices)
46}
47
48pub async fn select_device(
49    ip: Option<Ipv4Addr>,
50    index: Option<usize>,
51    iface: Option<&IfaceSelector>,
52    timeout: Duration,
53) -> Result<DeviceInfo> {
54    match (ip, index) {
55        (Some(ip), None) => {
56            let mut devices = discover_devices(timeout, iface).await?;
57            if let Some(found) = devices.drain(..).find(|dev| dev.ip == ip) {
58                return Ok(found);
59            }
60            Ok(DeviceInfo::from_ip(ip))
61        }
62        (None, Some(idx)) => {
63            let devices = discover_devices(timeout, iface).await?;
64            let device = devices
65                .into_iter()
66                .nth(idx)
67                .ok_or_else(|| anyhow!("no device at index {idx}"))?;
68            Ok(device)
69        }
70        (Some(ip), Some(_)) => {
71            bail!("specify either --ip or --index, not both (using {ip})");
72        }
73        (None, None) => {
74            bail!("a camera must be selected via --ip or --index");
75        }
76    }
77}
78
79/// Fetch a camera's GenApi XML, stopping before anything is made of it.
80///
81/// Deliberately separate from [`open_camera`]: the cameras whose XML we most
82/// want are the ones whose XML we cannot yet parse, so the dump must not
83/// depend on parsing succeeding.
84pub async fn fetch_xml(control: Arc<Mutex<GigeDevice>>) -> Result<String> {
85    viva_genapi_xml::fetch_and_load_xml({
86        move |address, length| {
87            let control = Arc::clone(&control);
88            async move {
89                let mut guard = control.lock().await;
90                guard
91                    .read_mem(address, length)
92                    .await
93                    .map_err(|err| XmlError::Transport(err.to_string()))
94            }
95        }
96    })
97    .await
98    .context("fetch GenApi XML")
99}
100
101pub async fn open_camera(device: &DeviceInfo) -> Result<Camera<GigeRegisterIo>> {
102    let addr = SocketAddr::new(IpAddr::V4(device.ip), GVCP_PORT);
103    let control =
104        Arc::new(Mutex::new(GigeDevice::open(addr).await.with_context(
105            || format!("connect GVCP control channel at {}", device.ip),
106        )?));
107    let xml = fetch_xml(control.clone()).await?;
108    let model = viva_genapi_xml::parse(&xml).context("parse GenApi XML")?;
109    let nodemap = NodeMap::try_from_xml(model)?;
110    let handle = Handle::current();
111    let device = Arc::try_unwrap(control)
112        .map_err(|_| anyhow!("control connection still in use"))?
113        .into_inner();
114    let transport = GigeRegisterIo::new(handle, device);
115    Ok(Camera::new(transport, nodemap))
116}
117
118/// Open the GVCP control channel and stop there.
119///
120/// Streaming, IP configuration and the XML dump all start here; none of them
121/// needs a nodemap, and one of them exists precisely because building a
122/// nodemap can fail.
123pub async fn open_control(device: &DeviceInfo) -> Result<GigeDevice> {
124    let addr = SocketAddr::new(IpAddr::V4(device.ip), GVCP_PORT);
125    GigeDevice::open(addr)
126        .await
127        .with_context(|| format!("connect GVCP control channel at {}", device.ip))
128}
129
130/// Resolve `--iface` against the host, whichever way the user spelled it.
131///
132/// The spelling is [`IfaceSelector`]'s problem, not this crate's: an IPv4
133/// address and an OS interface name are both accepted here, in `viva-service`
134/// and in the Python bindings, because a user who found a camera with one tool
135/// should be able to stream it with the next
136/// ([#109](https://github.com/VitalyVorobyev/viva-genicam/issues/109)).
137pub fn resolve_iface_selector(selector: &IfaceSelector) -> Result<Iface> {
138    selector
139        .resolve()
140        .with_context(|| format!("resolve host interface '{selector}'"))
141}
142
143/// Resolve the local interface that will receive packets from `camera_ip`.
144///
145/// Without `--iface`, ask the OS which local interface routes to the camera
146/// instead of refusing to run: `list`, `xml`, `report`, `get`, `set` and
147/// `set-ip` all tolerate a missing `--iface` and fall back to broadcast
148/// discovery, and `stream`, `bench` and `events` were the exceptions.
149///
150/// That mattered beyond consistency. `viva-camctl stream --ip <IP>` is the
151/// command our own documentation hands to anyone reporting a camera we cannot
152/// open, and it exited before touching the network. Meanwhile
153/// [`Iface::from_remote_ipv4`] — the route probe added by #72 *for this exact
154/// case*, and produced by that very issue — had no caller in this crate
155/// (backlog `DX-08`).
156///
157/// Note which side of the split each function serves: the selector names a
158/// **host** interface, while `camera_ip` is **remote**, so the fallback must
159/// be `from_remote_ipv4` and never `from_ipv4`. Passing a camera address to
160/// `from_ipv4` is the #70 defect, and `viva-service` still had a copy of it
161/// (backlog `SVC-06`).
162pub fn resolve_receive_iface(iface: Option<&IfaceSelector>, camera_ip: Ipv4Addr) -> Result<Iface> {
163    match iface {
164        Some(selector) => resolve_iface_selector(selector),
165        None => Iface::from_remote_ipv4(camera_ip).with_context(|| {
166            format!(
167                "probe which local interface routes to {camera_ip} \
168                 (pass --iface <HOST-IP|NAME> to choose one explicitly)"
169            )
170        }),
171    }
172}
173
174pub fn resolve_iface(iface: Option<&IfaceSelector>) -> Result<Option<Iface>> {
175    iface.map(resolve_iface_selector).transpose()
176}
177
178pub fn print_json<T: Serialize>(value: &T) -> Result<()> {
179    let text = serde_json::to_string_pretty(value).context("serialise JSON output")?;
180    println!("{text}");
181    Ok(())
182}
183
184pub fn format_system_time(ts: SystemTime) -> Result<String> {
185    let dt: OffsetDateTime = <SystemTime as std::convert::Into<OffsetDateTime>>::into(ts);
186    dt.format(&Rfc3339).context("format timestamp")
187}
188
189pub fn encode_pgm(width: u32, height: u32, data: &[u8]) -> Result<Vec<u8>> {
190    // Lossless, portable conversions (works on any pointer width)
191    let w: usize = width.try_into().context("width doesn't fit in usize")?;
192    let h: usize = height.try_into().context("height doesn't fit in usize")?;
193
194    // Guard against overflow in w * h
195    let expected = w.checked_mul(h).context("image area overflow")?;
196
197    if expected != data.len() {
198        bail!(
199            "PGM payload length mismatch: expected {expected}, got {}",
200            data.len()
201        );
202    }
203
204    let header = format!("P5\n{width} {height}\n255\n");
205    let mut buf = Vec::with_capacity(header.len() + data.len());
206    buf.extend_from_slice(header.as_bytes());
207    buf.extend_from_slice(data);
208    Ok(buf)
209}
210
211pub fn encode_ppm(width: u32, height: u32, data: &[u8]) -> Result<Vec<u8>> {
212    let w: usize = width.try_into().context("width doesn't fit in usize")?;
213    let h: usize = height.try_into().context("height doesn't fit in usize")?;
214
215    // Guard against overflow in w * h * 3 (RGB)
216    let expected = w
217        .checked_mul(h)
218        .and_then(|px| px.checked_mul(3))
219        .context("image area overflow")?;
220
221    if expected != data.len() {
222        bail!(
223            "PPM payload length mismatch: expected {expected}, got {}",
224            data.len()
225        );
226    }
227    let header = format!("P6\n{width} {height}\n255\n");
228    let mut buf = Vec::with_capacity(header.len() + data.len());
229    buf.extend_from_slice(header.as_bytes());
230    buf.extend_from_slice(data);
231    Ok(buf)
232}
233
234pub fn save_image(buffer: &[u8], path: &PathBuf) -> Result<()> {
235    let mut file = File::create(path).with_context(|| format!("create {}", path.display()))?;
236    file.write_all(buffer)
237        .with_context(|| format!("write {}", path.display()))?;
238    Ok(())
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn pgm_header_is_correct() {
247        let data = vec![0u8; 4];
248        let encoded = encode_pgm(2, 2, &data).expect("encode");
249        assert!(encoded.starts_with(b"P5\n2 2\n255\n"));
250        assert_eq!(encoded.len(), 4 + "P5\n2 2\n255\n".len());
251    }
252
253    #[test]
254    fn ppm_header_is_correct() {
255        let data = vec![0u8; 12];
256        let encoded = encode_ppm(2, 2, &data).expect("encode");
257        assert!(encoded.starts_with(b"P6\n2 2\n255\n"));
258        assert_eq!(encoded.len(), 12 + "P6\n2 2\n255\n".len());
259    }
260}