Skip to main content

viva_camctl/
cmd_report.rs

1//! `viva-camctl report` — one command that produces everything we ask for.
2//!
3//! Three issues, three real defects, and in every one of them the diagnosis
4//! came from an artifact the reporter had to assemble by hand: a debug log, a
5//! register dump, the GenApi XML. The maintainer has no cameras, so a report
6//! is not a support burden — it is the only evidence this project can obtain,
7//! and the cost of producing one is the rate limit on fixing bugs.
8//!
9//! So the report gathers all of it in one pass and keeps going when a step
10//! fails: a camera we cannot open is precisely the camera worth reporting, and
11//! a bundle that aborts at the first error would describe nothing. Every
12//! section records either its findings or why it has none.
13//!
14//! Output is plain text, in one file, because that is what an issue tracker
15//! accepts as an attachment.
16
17use std::fmt::Write as _;
18use std::net::Ipv4Addr;
19use std::path::PathBuf;
20use std::sync::Arc;
21use std::time::Duration;
22
23use anyhow::{Context, Result};
24use tokio::sync::Mutex;
25use viva_genapi_xml::XmlModel;
26use viva_genicam::genapi::NodeMap;
27use viva_gige::DeviceInfo;
28use viva_gige::gvcp::{GigeDevice, consts};
29use viva_gige::nic::{Iface, IfaceSelector};
30
31use crate::common;
32
33/// How to render a register's value beside its raw hex.
34///
35/// The raw word is always printed — a report exists to carry facts, and a
36/// decoding we got wrong must not be the only thing it says.
37#[derive(Clone, Copy)]
38enum Fmt {
39    /// Hex only: bitmasks, where a decimal reading tells nobody anything.
40    Bits,
41    Dec,
42    Ipv4,
43}
44
45/// Bootstrap registers worth dumping, as `(address, name, format)`.
46///
47/// Chosen for diagnostic value rather than completeness: the identity block,
48/// the capability words, the IP configuration that #57 turned on, and the
49/// channel registers whose addresses we had wrong until TC-13.
50const BOOTSTRAP: &[(u64, &str, Fmt)] = &[
51    (0x0000, "Version", Fmt::Bits),
52    (0x0004, "DeviceMode", Fmt::Bits),
53    (0x0008, "DeviceMACAddressHigh", Fmt::Bits),
54    (0x000C, "DeviceMACAddressLow", Fmt::Bits),
55    (0x0010, "SupportedIPConfiguration", Fmt::Bits),
56    (
57        consts::CURRENT_IP_CONFIG,
58        "CurrentIPConfiguration",
59        Fmt::Bits,
60    ),
61    (0x0024, "CurrentIPAddress", Fmt::Ipv4),
62    (0x0034, "CurrentSubnetMask", Fmt::Ipv4),
63    (0x0044, "CurrentDefaultGateway", Fmt::Ipv4),
64    (
65        consts::PERSISTENT_IP_ADDRESS,
66        "PersistentIPAddress",
67        Fmt::Ipv4,
68    ),
69    (
70        consts::PERSISTENT_SUBNET_MASK,
71        "PersistentSubnetMask",
72        Fmt::Ipv4,
73    ),
74    (
75        consts::PERSISTENT_DEFAULT_GATEWAY,
76        "PersistentDefaultGateway",
77        Fmt::Ipv4,
78    ),
79    (0x0900, "GevNumberOfMessageChannels", Fmt::Dec),
80    (0x0904, "GevNumberOfStreamChannels", Fmt::Dec),
81    (0x0938, "GevHeartbeatTimeout", Fmt::Dec),
82    (consts::CONTROL_CHANNEL_PRIVILEGE, "GevCCP", Fmt::Bits),
83    (consts::MESSAGE_DESTINATION_PORT, "GevMCP", Fmt::Dec),
84    (consts::MESSAGE_DESTINATION_ADDRESS, "GevMCDA", Fmt::Ipv4),
85    (consts::MESSAGE_CHANNEL_TIMEOUT, "GevMCTT", Fmt::Dec),
86    (consts::MESSAGE_CHANNEL_RETRY_COUNT, "GevMCRC", Fmt::Dec),
87    (
88        consts::STREAM_CHANNEL_BASE + consts::STREAM_DESTINATION_PORT,
89        "GevSCPHostPort[0]",
90        Fmt::Dec,
91    ),
92    (
93        consts::STREAM_CHANNEL_BASE + consts::STREAM_PACKET_SIZE,
94        "GevSCPSPacketSize[0]",
95        Fmt::Bits,
96    ),
97    (
98        consts::STREAM_CHANNEL_BASE + consts::STREAM_PACKET_DELAY,
99        "GevSCPD[0]",
100        Fmt::Dec,
101    ),
102    (
103        consts::STREAM_CHANNEL_BASE + consts::STREAM_DESTINATION_ADDRESS,
104        "GevSCDA[0]",
105        Fmt::Ipv4,
106    ),
107];
108
109fn decode(value: u32, fmt: Fmt) -> String {
110    match fmt {
111        Fmt::Bits => String::new(),
112        Fmt::Dec => value.to_string(),
113        Fmt::Ipv4 => Ipv4Addr::from(value).to_string(),
114    }
115}
116
117pub struct ReportArgs {
118    pub ip: Option<Ipv4Addr>,
119    pub index: Option<usize>,
120    pub iface: Option<IfaceSelector>,
121    pub out: Option<PathBuf>,
122    pub timeout_ms: u64,
123    /// Omit the GenApi XML. It is the single most useful artifact, so this is
124    /// opt-out rather than opt-in.
125    pub no_xml: bool,
126}
127
128pub async fn run(args: ReportArgs) -> Result<()> {
129    let mut out = String::new();
130    let _ = writeln!(out, "# viva-genicam diagnostic report");
131    let _ = writeln!(out);
132
133    environment(&mut out);
134    interfaces(&mut out);
135    let devices = discovery(&mut out, args.iface.as_ref(), args.timeout_ms).await;
136    camera(&mut out, &args, &devices).await;
137
138    let _ = writeln!(out, "## End of report");
139
140    match args.out {
141        Some(path) => {
142            std::fs::write(&path, out.as_bytes())
143                .with_context(|| format!("write {}", path.display()))?;
144            eprintln!(
145                "wrote {} bytes to {}\n\nAttach this file to \
146                 https://github.com/VitalyVorobyev/viva-genicam/issues",
147                out.len(),
148                path.display()
149            );
150        }
151        None => print!("{out}"),
152    }
153    Ok(())
154}
155
156fn environment(out: &mut String) {
157    let _ = writeln!(out, "## Environment");
158    let _ = writeln!(out);
159    let _ = writeln!(out, "viva-camctl: {}", env!("CARGO_PKG_VERSION"));
160    let _ = writeln!(
161        out,
162        "host:        {} {}",
163        std::env::consts::OS,
164        std::env::consts::ARCH
165    );
166    let _ = writeln!(out);
167}
168
169fn interfaces(out: &mut String) {
170    let _ = writeln!(out, "## Network interfaces");
171    let _ = writeln!(out);
172    let _ = writeln!(
173        out,
174        "As the library sees them. An interface missing here is invisible to \
175         discovery no matter what the OS reports elsewhere (#57)."
176    );
177    let _ = writeln!(out);
178    // `mtu()` only queries the OS on Linux and returns 1500 everywhere else
179    // (TC-11). Printing that as though it had been measured would put a
180    // fabricated number in a document whose whole purpose is evidence.
181    let mtu_measured = cfg!(target_os = "linux");
182    match Iface::list() {
183        Ok(ifaces) if ifaces.is_empty() => {
184            let _ = writeln!(out, "(none reported)");
185        }
186        Ok(ifaces) => {
187            for iface in ifaces {
188                let addrs = iface.all_ipv4().unwrap_or_default();
189                let addrs = if addrs.is_empty() {
190                    "-".to_string()
191                } else {
192                    addrs
193                        .iter()
194                        .map(|ip| ip.to_string())
195                        .collect::<Vec<_>>()
196                        .join(", ")
197                };
198                let mtu = if mtu_measured {
199                    viva_gige::nic::mtu(&iface)
200                        .map(|mtu| mtu.to_string())
201                        .unwrap_or_else(|err| format!("unknown ({err})"))
202                } else {
203                    "assumed".to_string()
204                };
205                let _ = writeln!(
206                    out,
207                    "{:<16} index={:<5} mtu={:<9} ipv4=[{}]",
208                    iface.name(),
209                    iface.index(),
210                    mtu,
211                    addrs
212                );
213            }
214            if !mtu_measured {
215                let _ = writeln!(
216                    out,
217                    "\nMTU is not queried on {} — the library assumes 1500 and \
218                     cannot select jumbo frames here (TC-11).",
219                    std::env::consts::OS
220                );
221            }
222        }
223        Err(err) => {
224            let _ = writeln!(out, "FAILED to enumerate interfaces: {err}");
225        }
226    }
227    let _ = writeln!(out);
228}
229
230async fn discovery(
231    out: &mut String,
232    iface: Option<&IfaceSelector>,
233    timeout_ms: u64,
234) -> Vec<DeviceInfo> {
235    let _ = writeln!(out, "## Discovery");
236    let _ = writeln!(out);
237    let timeout = Duration::from_millis(timeout_ms);
238    match common::discover_devices(timeout, iface).await {
239        Ok(devices) if devices.is_empty() => {
240            let _ = writeln!(out, "no cameras answered within {timeout_ms} ms");
241            let _ = writeln!(out);
242            Vec::new()
243        }
244        Ok(devices) => {
245            for (index, dev) in devices.iter().enumerate() {
246                let _ = writeln!(out, "[{index}] {}", dev.ip);
247                let _ = writeln!(out, "     mac:          {}", dev.mac_string());
248                let _ = writeln!(out, "     manufacturer: {}", opt(&dev.manufacturer));
249                let _ = writeln!(out, "     model:        {}", opt(&dev.model));
250                let _ = writeln!(out, "     version:      {}", opt(&dev.version));
251                let _ = writeln!(out, "     serial:       {}", opt(&dev.serial));
252                let _ = writeln!(out, "     user name:    {}", opt(&dev.user_name));
253            }
254            let _ = writeln!(out);
255            devices
256        }
257        Err(err) => {
258            let _ = writeln!(out, "FAILED: {err:#}");
259            let _ = writeln!(out);
260            Vec::new()
261        }
262    }
263}
264
265async fn camera(out: &mut String, args: &ReportArgs, discovered: &[DeviceInfo]) {
266    let _ = writeln!(out, "## Camera");
267    let _ = writeln!(out);
268
269    let device = match select(args, discovered) {
270        Some(device) => device,
271        None => {
272            let _ = writeln!(
273                out,
274                "No camera selected. Pass --ip or --index to include register, \
275                 XML and feature sections."
276            );
277            let _ = writeln!(out);
278            return;
279        }
280    };
281    let _ = writeln!(out, "selected: {}", device.ip);
282    let _ = writeln!(out);
283
284    let control = match common::open_control(&device).await {
285        Ok(control) => control,
286        Err(err) => {
287            let _ = writeln!(out, "FAILED to open the control channel: {err:#}");
288            let _ = writeln!(out);
289            return;
290        }
291    };
292    let control = Arc::new(Mutex::new(control));
293
294    bootstrap_registers(out, &control).await;
295
296    let xml = match common::fetch_xml(Arc::clone(&control)).await {
297        Ok(xml) => xml,
298        Err(err) => {
299            let _ = writeln!(out, "## GenApi XML");
300            let _ = writeln!(out);
301            let _ = writeln!(out, "FAILED to fetch: {err:#}");
302            let _ = writeln!(out);
303            return;
304        }
305    };
306    genapi(out, &xml, args.no_xml);
307}
308
309fn select(args: &ReportArgs, discovered: &[DeviceInfo]) -> Option<DeviceInfo> {
310    if let Some(ip) = args.ip {
311        // Falling back to a bare address matters here: a camera that discovery
312        // cannot see may still answer on a known IP, and that gap is itself
313        // worth reporting.
314        return Some(
315            discovered
316                .iter()
317                .find(|dev| dev.ip == ip)
318                .cloned()
319                .unwrap_or_else(|| DeviceInfo::from_ip(ip)),
320        );
321    }
322    args.index.and_then(|index| discovered.get(index).cloned())
323}
324
325async fn bootstrap_registers(out: &mut String, control: &Arc<Mutex<GigeDevice>>) {
326    let _ = writeln!(out, "## Bootstrap registers");
327    let _ = writeln!(out);
328    let mut guard = control.lock().await;
329    for (addr, name, fmt) in BOOTSTRAP {
330        let value = match guard.read_register(*addr as u32).await {
331            Ok(value) => format!("0x{value:08X}  {}", decode(value, *fmt)),
332            Err(err) => format!("read failed: {err}"),
333        };
334        let _ = writeln!(out, "0x{addr:04X}  {name:<26} {}", value.trim_end());
335    }
336    let _ = writeln!(out);
337}
338
339fn genapi(out: &mut String, xml: &str, no_xml: bool) {
340    let _ = writeln!(out, "## GenApi");
341    let _ = writeln!(out);
342    let _ = writeln!(out, "XML size: {} bytes", xml.len());
343
344    let model: Option<XmlModel> = match viva_genapi_xml::parse(xml) {
345        Ok(model) => {
346            let _ = writeln!(
347                out,
348                "schema:   {}\nparsed:   {} nodes",
349                model.version,
350                model.nodes.len()
351            );
352            Some(model)
353        }
354        Err(err) => {
355            let _ = writeln!(out, "PARSE FAILED: {err}");
356            None
357        }
358    };
359
360    if let Some(model) = model {
361        match NodeMap::try_from_xml(model) {
362            Ok(nodemap) => {
363                let _ = writeln!(out, "built:    {} features", nodemap.node_names().count());
364                let _ = writeln!(out);
365                let skipped = nodemap.skipped();
366                if skipped.is_empty() {
367                    let _ = writeln!(out, "No features were dropped.");
368                } else {
369                    let _ = writeln!(
370                        out,
371                        "{} feature(s) this camera has that we cannot expose — \
372                         these are the interesting ones:",
373                        skipped.len()
374                    );
375                    let _ = writeln!(out);
376                    for node in skipped {
377                        let _ = writeln!(
378                            out,
379                            "  <{}> {}: {}",
380                            node.tag,
381                            node.name.as_deref().unwrap_or("<unnamed>"),
382                            node.error
383                        );
384                    }
385                }
386            }
387            Err(err) => {
388                let _ = writeln!(out, "NODEMAP BUILD FAILED: {err}");
389            }
390        }
391    }
392    let _ = writeln!(out);
393
394    if no_xml {
395        let _ = writeln!(
396            out,
397            "## GenApi XML\n\nOmitted (--no-xml). Re-run without it, or use \
398             `viva-camctl xml`, if asked for the document."
399        );
400    } else {
401        let _ = writeln!(out, "## GenApi XML");
402        let _ = writeln!(out);
403        let _ = writeln!(out, "{xml}");
404    }
405    let _ = writeln!(out);
406}
407
408fn opt(value: &Option<String>) -> &str {
409    value.as_deref().unwrap_or("-")
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn bootstrap_addresses_are_unique() {
418        let mut seen = std::collections::HashSet::new();
419        for (addr, name, _) in BOOTSTRAP {
420            assert!(seen.insert(*addr), "duplicate address for {name}");
421        }
422    }
423
424    #[test]
425    fn ip_registers_decode_to_dotted_quads() {
426        assert_eq!(decode(0x7F00_0001, Fmt::Ipv4), "127.0.0.1");
427        assert_eq!(decode(0xFF00_0000, Fmt::Ipv4), "255.0.0.0");
428        assert_eq!(decode(3000, Fmt::Dec), "3000");
429        assert_eq!(decode(0x8000_0001, Fmt::Bits), "");
430    }
431
432    /// A camera we cannot understand is the one worth reporting, so the
433    /// GenApi section must describe the failure rather than abort the report.
434    #[test]
435    fn unparsable_xml_still_produces_a_section() {
436        let mut out = String::new();
437        genapi(&mut out, "<not-genicam>", true);
438        assert!(out.contains("PARSE FAILED"), "{out}");
439        assert!(out.contains("XML size: 13 bytes"), "{out}");
440    }
441
442    #[test]
443    fn skipped_features_are_listed() {
444        const XML: &str = r#"
445            <RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="1" SchemaSubMinorVersion="0">
446                <ConfRom Name="DeviceConfRom">
447                    <Address>0x2000</Address>
448                    <Length>512</Length>
449                </ConfRom>
450            </RegisterDescription>
451        "#;
452        let mut out = String::new();
453        genapi(&mut out, XML, true);
454        assert!(out.contains("<ConfRom> DeviceConfRom"), "{out}");
455        assert!(out.contains("cannot expose"), "{out}");
456    }
457
458    #[test]
459    fn the_xml_is_included_unless_opted_out() {
460        const XML: &str = r#"<RegisterDescription SchemaMajorVersion="1" SchemaMinorVersion="1" SchemaSubMinorVersion="0"/>"#;
461        let mut with = String::new();
462        genapi(&mut with, XML, false);
463        assert!(with.contains("SchemaMajorVersion"), "{with}");
464
465        let mut without = String::new();
466        genapi(&mut without, XML, true);
467        assert!(without.contains("Omitted (--no-xml)"), "{without}");
468    }
469}