Skip to main content

viva_camctl/
cli.rs

1use std::net::Ipv4Addr;
2use std::path::PathBuf;
3
4use anyhow::Result;
5use clap::{ArgAction, Parser, Subcommand};
6use tracing_subscriber::EnvFilter;
7use viva_gige::nic::IfaceSelector;
8
9use crate::cmd_bench::{self, BenchArgs};
10use crate::cmd_chunks;
11use crate::cmd_events;
12use crate::cmd_execute;
13use crate::cmd_get;
14use crate::cmd_list;
15use crate::cmd_report::{self, ReportArgs};
16use crate::cmd_set;
17use crate::cmd_set_ip;
18use crate::cmd_stream::{self, StreamArgs};
19use crate::cmd_usb;
20use crate::cmd_xml::{self, XmlArgs};
21
22#[derive(Parser, Debug)]
23#[command(name = "viva-camctl", version, about = "GenICam CLI")]
24pub struct Cli {
25    /// Increase verbosity (-v, -vv, -vvv)
26    #[arg(short, long, action = ArgAction::Count)]
27    verbose: u8,
28    /// Output JSON where applicable
29    #[arg(long)]
30    json: bool,
31    /// Host interface to use, named either by one of its IPv4 addresses
32    /// (`169.254.105.106`) or by its OS name (`en0`, or a GUID on Windows)
33    #[arg(long)]
34    iface: Option<IfaceSelector>,
35    #[command(subcommand)]
36    cmd: Cmd,
37}
38
39#[derive(Subcommand, Debug)]
40pub enum Cmd {
41    /// Discover cameras (GVCP)
42    List {
43        #[arg(long, default_value_t = 1000)]
44        timeout_ms: u64,
45        /// Host interface, by IPv4 address or by OS name
46        #[arg(long)]
47        iface: Option<IfaceSelector>,
48    },
49    /// Read a feature via GenApi NodeMap
50    Get {
51        #[arg(long)]
52        ip: Option<Ipv4Addr>,
53        #[arg(long)]
54        index: Option<usize>,
55        #[arg(long)]
56        name: String,
57    },
58    /// Dump the camera's GenApi XML (no parsing, so a document we cannot
59    /// read still comes out)
60    Xml {
61        #[arg(long)]
62        ip: Option<Ipv4Addr>,
63        #[arg(long)]
64        index: Option<usize>,
65        /// Host interface, by IPv4 address or by OS name
66        #[arg(long)]
67        iface: Option<IfaceSelector>,
68        /// Write to this file instead of stdout
69        #[arg(long)]
70        out: Option<PathBuf>,
71    },
72    /// Collect a diagnostic bundle to attach to a bug report
73    Report {
74        #[arg(long)]
75        ip: Option<Ipv4Addr>,
76        #[arg(long)]
77        index: Option<usize>,
78        /// Host interface, by IPv4 address or by OS name
79        #[arg(long)]
80        iface: Option<IfaceSelector>,
81        /// Write the bundle here. `.txt` because GitHub rejects `.xml`
82        /// attachments.
83        #[arg(long, default_value = "viva-report.txt")]
84        out: PathBuf,
85        /// Print to stdout instead of writing a file
86        #[arg(long, conflicts_with = "out")]
87        stdout: bool,
88        #[arg(long, default_value_t = 1000)]
89        timeout_ms: u64,
90        /// Leave the GenApi XML out of the bundle
91        #[arg(long)]
92        no_xml: bool,
93    },
94    /// Write a feature via GenApi NodeMap
95    Set {
96        #[arg(long)]
97        ip: Option<Ipv4Addr>,
98        #[arg(long)]
99        index: Option<usize>,
100        #[arg(long)]
101        name: String,
102        #[arg(long)]
103        value: String,
104    },
105    /// Execute a GenApi Command feature (e.g. `UserSetLoad`)
106    Execute {
107        #[arg(long)]
108        ip: Option<Ipv4Addr>,
109        #[arg(long)]
110        index: Option<usize>,
111        #[arg(long)]
112        name: String,
113    },
114    /// Start GVSP stream (uni-/multicast)
115    Stream {
116        #[arg(long)]
117        ip: Option<Ipv4Addr>,
118        #[arg(long)]
119        index: Option<usize>,
120        /// Host interface, by IPv4 address or by OS name
121        #[arg(long)]
122        iface: Option<IfaceSelector>,
123        #[arg(long, default_value = "unicast")]
124        mode: String,
125        #[arg(long)]
126        group: Option<Ipv4Addr>,
127        #[arg(long, default_value_t = 10040)]
128        port: u16,
129        /// Set GevSCPSPacketSize from the host NIC MTU, then path-probe
130        /// (ADR-0021). Mutually exclusive with --packet-size. Default is to
131        /// leave the camera's current value alone.
132        #[arg(long, conflicts_with = "packet_size")]
133        auto: bool,
134        /// Override the GVSP packet size (ceiling). Default preserves the
135        /// camera's GevSCPSPacketSize. Mutually exclusive with --auto.
136        #[arg(long, conflicts_with = "auto")]
137        packet_size: Option<u32>,
138        #[arg(long, default_value_t = 1)]
139        save: usize,
140        #[arg(long)]
141        rgb: bool,
142        #[arg(long, default_value_t = 0)]
143        duration_s: u64,
144    },
145    /// Configure + read events (message channel)
146    Events {
147        #[arg(long)]
148        ip: Option<Ipv4Addr>,
149        #[arg(long)]
150        index: Option<usize>,
151        /// Host interface, by IPv4 address or by OS name
152        #[arg(long)]
153        iface: Option<IfaceSelector>,
154        #[arg(long, default_value_t = 10020)]
155        port: u16,
156        #[arg(long, default_value = "FrameStart,ExposureEnd")]
157        enable: String,
158        #[arg(long, default_value_t = 10)]
159        count: u32,
160    },
161    /// Toggle ChunkModeActive + selectors
162    Chunks {
163        #[arg(long)]
164        ip: Option<Ipv4Addr>,
165        #[arg(long)]
166        index: Option<usize>,
167        #[arg(long)]
168        enable: bool,
169        #[arg(long, default_value = "Timestamp")]
170        selectors: String,
171    },
172    /// Sustained stream soak/benchmark
173    Bench {
174        #[arg(long)]
175        ip: Option<Ipv4Addr>,
176        #[arg(long)]
177        index: Option<usize>,
178        /// Host interface, by IPv4 address or by OS name
179        #[arg(long)]
180        iface: Option<IfaceSelector>,
181        #[arg(long, default_value = "unicast")]
182        mode: String,
183        #[arg(long)]
184        group: Option<Ipv4Addr>,
185        #[arg(long, default_value_t = 10040)]
186        port: u16,
187        #[arg(long, default_value_t = 300)]
188        duration_s: u64,
189        #[arg(long)]
190        json_out: Option<PathBuf>,
191    },
192    /// Configure IP address of a GigE camera
193    SetIp {
194        /// MAC address (e.g. DE:AD:BE:EF:CA:FE)
195        #[arg(long)]
196        mac: String,
197        /// IP address to assign
198        #[arg(long)]
199        ip: Ipv4Addr,
200        /// Subnet mask
201        #[arg(long, default_value = "255.255.255.0")]
202        subnet: Ipv4Addr,
203        /// Default gateway
204        #[arg(long, default_value = "0.0.0.0")]
205        gateway: Ipv4Addr,
206        /// Use FORCEIP (temporary) instead of persistent registers
207        #[arg(long)]
208        force: bool,
209    },
210    /// Discover USB3 Vision cameras
211    ListUsb,
212    /// Read a feature from a USB3 Vision camera
213    GetUsb {
214        #[arg(long)]
215        index: Option<usize>,
216        #[arg(long)]
217        name: String,
218    },
219    /// Write a feature to a USB3 Vision camera
220    SetUsb {
221        #[arg(long)]
222        index: Option<usize>,
223        #[arg(long)]
224        name: String,
225        #[arg(long)]
226        value: String,
227    },
228    /// Stream frames from a USB3 Vision camera
229    StreamUsb {
230        #[arg(long)]
231        index: Option<usize>,
232        /// Number of frames to save to disk
233        #[arg(long, default_value_t = 1)]
234        save: usize,
235        /// Convert saved frames to RGB
236        #[arg(long)]
237        rgb: bool,
238        /// Stop after this many seconds (0 = unlimited)
239        #[arg(long, default_value_t = 0)]
240        duration_s: u64,
241    },
242}
243
244/// Install the tracing subscriber for a CLI run.
245///
246/// `try_init` rather than `init`: when the CLI is invoked through the Python
247/// wheel's console script it runs inside a process that may already have a
248/// global subscriber, and a second CLI invocation in the same process must not
249/// abort.
250fn init_tracing(verbose: u8) {
251    let level = match verbose {
252        0 => "info",
253        1 => "debug",
254        _ => "trace",
255    };
256    let _ = tracing_subscriber::fmt()
257        .with_env_filter(EnvFilter::new(
258            std::env::var("RUST_LOG").unwrap_or_else(|_| level.into()),
259        ))
260        .with_target(false)
261        .try_init();
262}
263
264/// Run the command a parsed [`Cli`] selected.
265pub async fn dispatch(cli: Cli) -> Result<()> {
266    let Cli {
267        verbose,
268        json,
269        iface,
270        cmd,
271    } = cli;
272
273    init_tracing(verbose);
274
275    match cmd {
276        Cmd::List {
277            timeout_ms,
278            iface: cmd_iface,
279        } => {
280            let iface = cmd_iface.or(iface);
281            cmd_list::run(timeout_ms, iface, json).await?
282        }
283        Cmd::Get { ip, index, name } => cmd_get::run(ip, index, name, iface, json).await?,
284        Cmd::Xml {
285            ip,
286            index,
287            iface: cmd_iface,
288            out,
289        } => {
290            let args = XmlArgs {
291                ip,
292                index,
293                iface: cmd_iface.or(iface),
294                out,
295            };
296            cmd_xml::run(args).await?
297        }
298        Cmd::Report {
299            ip,
300            index,
301            iface: cmd_iface,
302            out,
303            stdout,
304            timeout_ms,
305            no_xml,
306        } => {
307            let args = ReportArgs {
308                ip,
309                index,
310                iface: cmd_iface.or(iface),
311                out: (!stdout).then_some(out),
312                timeout_ms,
313                no_xml,
314            };
315            cmd_report::run(args).await?
316        }
317        Cmd::Set {
318            ip,
319            index,
320            name,
321            value,
322        } => cmd_set::run(ip, index, name, value, iface, json).await?,
323        Cmd::Execute { ip, index, name } => cmd_execute::run(ip, index, name, iface, json).await?,
324        Cmd::Stream {
325            ip,
326            index,
327            iface: cmd_iface,
328            mode,
329            group,
330            port,
331            packet_size,
332            auto,
333            save,
334            rgb,
335            duration_s,
336        } => {
337            let args = StreamArgs {
338                ip,
339                index,
340                iface: cmd_iface.or(iface),
341                mode,
342                group,
343                port,
344                packet_size,
345                auto,
346                save,
347                rgb,
348                duration_s,
349            };
350            cmd_stream::run(args).await?
351        }
352        Cmd::Events {
353            ip,
354            index,
355            iface: cmd_iface,
356            port,
357            enable,
358            count,
359        } => cmd_events::run(ip, index, cmd_iface.or(iface), port, enable, count, json).await?,
360        Cmd::Chunks {
361            ip,
362            index,
363            enable,
364            selectors,
365        } => cmd_chunks::run(ip, index, enable, selectors, iface, json).await?,
366        Cmd::Bench {
367            ip,
368            index,
369            iface: cmd_iface,
370            mode,
371            group,
372            port,
373            duration_s,
374            json_out,
375        } => {
376            let args = BenchArgs {
377                ip,
378                index,
379                iface: cmd_iface.or(iface),
380                mode,
381                group,
382                port,
383                duration_s,
384                json_out,
385            };
386            cmd_bench::run(args, json).await?
387        }
388        Cmd::SetIp {
389            mac,
390            ip,
391            subnet,
392            gateway,
393            force,
394        } => cmd_set_ip::run(&mac, ip, subnet, gateway, force, iface).await?,
395        Cmd::ListUsb => cmd_usb::run_list(json)?,
396        Cmd::GetUsb { index, name } => cmd_usb::run_get(index, name, json)?,
397        Cmd::SetUsb { index, name, value } => cmd_usb::run_set(index, name, value, json)?,
398        Cmd::StreamUsb {
399            index,
400            save,
401            rgb,
402            duration_s,
403        } => cmd_usb::run_stream(index, save, rgb, duration_s)?,
404    };
405
406    Ok(())
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    #[test]
414    fn parse_list_defaults() {
415        let cli = Cli::parse_from(["viva-camctl", "list"]);
416        match cli.cmd {
417            Cmd::List { timeout_ms, .. } => assert_eq!(timeout_ms, 1000),
418            other => panic!("unexpected variant: {other:?}"),
419        }
420    }
421
422    #[test]
423    fn parse_stream_args() {
424        let cli = Cli::parse_from([
425            "viva-camctl",
426            "stream",
427            "--mode",
428            "multicast",
429            "--group",
430            "239.1.1.1",
431            "--port",
432            "12000",
433        ]);
434        match cli.cmd {
435            Cmd::Stream {
436                mode, port, group, ..
437            } => {
438                assert_eq!(mode, "multicast");
439                assert_eq!(port, 12000);
440                assert_eq!(group, Some("239.1.1.1".parse().unwrap()));
441            }
442            other => panic!("unexpected variant: {other:?}"),
443        }
444    }
445
446    #[test]
447    fn parse_bench_output_path() {
448        let cli = Cli::parse_from(["viva-camctl", "bench", "--json-out", "bench.json"]);
449        match cli.cmd {
450            Cmd::Bench { json_out, .. } => {
451                assert_eq!(json_out, Some(PathBuf::from("bench.json")));
452            }
453            other => panic!("unexpected variant: {other:?}"),
454        }
455    }
456}