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