Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Welcome & Goals

viva-genicam provides pure Rust building blocks for the GenICam ecosystem supporting GigE Vision and USB3 Vision, with first-class support for Windows, Linux, and macOS.

Who is this book for?

  • End-users building camera applications who want a practical high-level API and copy-pasteable examples.
  • Contributors extending transports, GenApi features, and streaming – who need a clear mental model of crates and internal boundaries.

What works today

  • GigE Vision: GVCP discovery, GVSP streaming with frame reassembly, events, action commands, chunk parsing, FORCEIP, persistent IP configuration.
  • USB3 Vision: device discovery, GenCP register I/O, bulk-endpoint streaming, async frame iterator.
  • GenApi: NodeMap with all standard node types (Integer, Float, Enum, Boolean, Command, Category, String, SwissKnife, Converter), pValue delegation, selectors, runtime access predicates (pIsLocked, pIsAvailable, pIsImplemented), node metadata and visibility filtering.
  • CLI (viva-camctl): discovery, feature get/set, streaming, events, chunks, benchmarks, IP configuration, and a diagnostic report bundle.
  • Service bridge: expose cameras over Zenoh for Viva Studio, the desktop app in studio/.

What does not work yet

Worth knowing before you build on it:

  • Packet resend is not wired in. The GVSP resend machinery exists and nothing calls it, so the resends statistic is always zero and means “not implemented” rather than “none were needed”. Watch drops.
  • The Python bindings are narrower than the Rust API — no chunks, events, time sync or action commands. Python bindings lists the gaps.
  • Viva Studio is experimental. It works, and it has been driven against very little hardware.

The protocol implementations follow the published EMVA specifications and are validated against built-in fake camera simulators (190+ automated tests). Testing against physical cameras from different manufacturers is ongoing – bug reports and compatibility feedback are welcome.

How this book is organized

  • Start with Quick Start to build, test, and run the first discovery.
  • Read the Primer and Architecture to get the big picture.
  • Use Crate Guides and Tutorials for hands-on tasks.
  • See Networking and Troubleshooting when packets don’t behave.

Quick Start

This guide gets you from checkout to discovering cameras in minutes.

Prerequisites

  • Rust: 1.88 or newer (edition 2024).
  • OS: Windows, Linux, or macOS.
  • Network (GigE Vision):
    • Allow UDP broadcast on the NIC you’ll use for discovery.
    • Optional: enable jumbo frames on that NIC for high‑throughput streaming tests.

Build & Test

# From the repo root:
cargo build --workspace

# Run all tests
cargo test --workspace

# Generate local API docs (rustdoc)
cargo doc --workspace --no-deps

First run: Discovery examples

You can try discovery in two ways—either via the high‑level viva-genicam crate example or the viva-camctl CLI.

Option A: Example (genicam crate)

# List cameras via GVCP broadcast
cargo run -p viva-genicam --example list_cameras

Option B: CLI (viva-camctl)

# Discover cameras on the selected interface (IPv4 of your NIC)
cargo run -p viva-camctl -- list --iface 192.168.0.5

Control path: read / write & XML

# Read a feature by name
cargo run -p viva-camctl -- get --ip 192.168.0.10 --name ExposureTime

# Set a feature value
cargo run -p viva-camctl -- set --ip 192.168.0.10 --name ExposureTime --value 5000

# Dump the camera's GenApi XML
cargo run -p viva-camctl -- xml --ip 192.168.0.10 --out camera.xml

When something does not work

# Collect everything a bug report needs, in one file
cargo run -p viva-camctl -- report --ip 192.168.0.10 --out viva-report.txt

The report lists the network interfaces the library can see, the camera’s reply to discovery, its bootstrap registers, its GenApi XML, and any feature the camera has that this library could not build. Neither report nor xml needs the camera to open successfully — that is what they are for — so both still produce output when nothing else does. Attach the file to an issue.

Streaming (early GVSP)

# Receive a GVSP stream. Default leaves GevSCPSPacketSize alone (ADR-0021).
# --auto: NIC MTU then path bisect. --packet-size: explicit ceiling.
cargo run -p viva-camctl -- stream --ip 192.168.0.10 --iface 192.168.0.5 --auto --save 2

See Streaming → Packet size and MTU and ADR-0021.

Windows specifics

  • Run the terminal as Administrator the first time to let the firewall prompt appear.
  • Add inbound UDP rules for discovery and streaming.
  • Enable jumbo frames per NIC if your network supports it (helps at high FPS).

Next steps

  • Read the Primer for the concepts behind discovery, control, and streaming.
  • Jump to the Tutorial: Discover devices for a step‑by‑step walkthrough with troubleshooting tips.

GenICam & Vision Standards Primer

This chapter orients you in the standards and shows how they map to the crates in this repo. If you’re an end‑user, skim the concepts and jump to tutorials. If you’re a contributor, the mappings help you navigate the code.

1) Control vs. Data paths (big picture)

  • Control: configure the device, read status, fetch the GenApi XML. In GigE Vision, control is GVCP (GigE Vision Control Protocol, UDP) carrying GenCP (Generic Control Protocol) semantics for register reads/writes and feature access.
  • Data: receive image/metadata stream(s). In GigE Vision, data is GVSP (GigE Vision Streaming Protocol, UDP), typically one-way from camera → host.
  • Events & Actions: GVCP supports device→host events and host→device action commands for sync/triggering.
   +------------------------+        +--------------------+
   |        Host            |        |     Camera         |
   |  (this repository)     |        |  (GigE Vision)     |
   +-----------+------------+        +----------+---------+
               | GVCP (UDP, control)           |
               |  GenCP (registers/features)   |
               v                                ^
       Configure, query, XML                    |
                                                |
               ^                                v
               | GVSP (UDP, data)         Image/Chunks
               | (streaming)                    |

2) GenApi XML & NodeMap

  • The device exposes an XML description of its features (nodes). Nodes form a graph with types like Integer, Float, Boolean, Enumeration, Command, String, Register, and expression nodes like SwissKnife.
  • Nodes have AccessMode (RO/RW), Visibility (Beginner/Expert/Guru), Units, Min/Max/Inc, Selector links, and Dependencies (i.e., a node’s value depends on other nodes).
  • The host builds a NodeMap from the XML and evaluates nodes on demand: some read/write device registers; others compute values from expressions.

SwissKnife (implemented)

  • A SwissKnife node computes its value from an expression referencing other nodes (e.g., arithmetic, logic, conditionals). Typical uses:
    • Derive human‑readable features from raw register fields.
    • Apply scale/offset and conditionals depending on selectors.
  • In this project, SwissKnife is evaluated in the NodeMap, so reads of dependent nodes trigger the calculation transparently.

Selectors

  • Selectors (e.g., GainSelector) change the addressing or active branch so the same feature name maps to different underlying registers or computed paths.

3) Streaming: GVSP

  • UDP packets carry payloads (image data/metadata). The host reassembles frames, handles resend requests, negotiates packet size/MTU, and may introduce packet delay to avoid NIC/driver overflow.
  • Chunks: optional metadata blocks (e.g., Timestamp, ExposureTime) can be enabled and parsed alongside image data.
  • Time mapping: devices often use tick counters; the host maintains a mapping between device ticks and host time for cross‑correlation.

4) How standards map to crates

ConceptCrateResponsibility
GenCP (encode/decode, status)viva-gencpMessage formats, errors, helpers for control-path operations
GVCP/GVSP (GigE Vision)viva-gigeDiscovery, control channel, streaming engine, resend/MTU/delay, events/actions
GenApi XML loaderviva-genapi-xmlFetch XML via control path and parse schema‑lite into an internal representation
NodeMap & evaluationviva-genapiNode types (incl. SwissKnife), dependency resolution, selector routing, value get/set
Public façadeviva-genicamEnd‑user API combining transport + NodeMap + utilities (examples live here)

5) USB3 Vision (preview)

  • Similar split between control and data paths, but with USB3 transport and different discovery/endpoint mechanics. The higher‑level GenApi and NodeMap concepts remain the same.
  • Architecture Overview for a code‑level view of modules, traits, and async/concurrency.
  • Crate Guides for deep dives (APIs, examples, edge cases).
  • Tutorials to configure features and receive frames end‑to‑end.

Architecture Overview

This section maps the runtime flow, crate boundaries, and key traits so both app developers and contributors can reason about the system.

Layered view

+---------------------------+   End‑user API & examples
|   genicam (façade)        |   - device discovery, feature get/set
|   crates/viva-genicam/examples |   - streaming helpers, CLI wiring
+-------------+-------------+
|
v
+---------------------------+   GenApi core
|   viva-genapi             |   - Node types (Integer/Float/Enum/Bool/Command,
|                           |     Register, String, **SwissKnife**)
|                           |   - NodeMap build & evaluation
|                           |   - Selector routing & dependency graph
+-------------+-------------+
|
v
+---------------------------+   GenApi XML
|   viva-genapi-xml         |   - Fetch XML via control path
|                           |   - Parse schema‑lite → IR used by viva-genapi
+-------------+-------------+
|
v
+---------------------------+   Transports
|   viva-gige               |   - GVCP (control): discovery, read/write, events,
|                           |     action commands
|                           |   - GVSP (data): receive, reassembly, resend,
|                           |     MTU/packet size negotiation, delay, stats
+-------------+-------------+
|
v
+---------------------------+   Protocol helpers
|   viva-gencp              |   - GenCP encode/decode, status codes, helpers
+---------------------------+

Data flow

  1. Discovery (viva-gige): bind to NIC → broadcast GVCP discovery → parse replies.
  2. Connect: establish control channel (UDP) and prepare stream endpoints if needed.
  3. GenApi XML (viva-genapi-xml): read address from device registers → fetch XML → parse to IR.
  4. NodeMap (viva-genapi): build nodes, resolve links (Includes, Pointers, Selectors), set defaults.
  5. Evaluation (viva-genapi):
    • Direct nodes read/write underlying registers via viva-gige + viva-gencp.
    • Computed nodes (e.g., SwissKnife) evaluate expressions that reference other nodes.
  6. Streaming (viva-gige): configure packet size/delay → receive GVSP → reassemble → expose frames + chunks and timestamps.

Async, threading, and I/O

  • Transport uses async UDP sockets (Tokio) and bounded channels for back‑pressure.
  • Frame reassembly runs on dedicated tasks; statistics are aggregated periodically.
  • Node evaluation is sync from the caller’s perspective; I/O hops are awaited within accessors.

Error handling & tracing

  • Errors are categorized by layer (transport/protocol/genapi/eval). Use anyhow/custom error types at boundaries.
  • Enable logs with RUST_LOG=info (or debug,trace) and consider JSON output for tooling.

Platform considerations

  • Windows/Linux/macOS supported. On Windows, run discovery once as admin to authorize firewall; consider jumbo frames per NIC for high FPS.
  • Multi‑NIC hosts should explicitly select the interface for discovery/streaming.

Extending the system

  • Add nodes in viva-genapi by implementing the evaluation trait and wiring dependencies.
  • Add transports as new viva-* crates behind a trait the facade can select at runtime.
  • Keep viva-genicam thin: compose transport + NodeMap + utilities; keep heavy logic in lower crates.

Crates overview

The viva-genicam workspace is split into small crates that mirror the structure of the GenICam ecosystem:

  • Protocols & transport (GenCP, GVCP/GVSP)
  • GenApi XML loading & evaluation
  • Public “facade” API for applications
  • Command-line tooling for everyday camera work

This chapter is the “map of the territory”. It tells you which crate to use for a given task, and where to look if you want to hack on internals.


Quick map

Every crate lives under crates/ and is named for its directory.

CrateRole / responsibilityPrimary audience
viva-genicamHigh-level facade combining transport + GenApiEnd-users — start here
viva-camctlCLI: discovery, features, streaming, diagnostics, IP configEnd-users, ops, CI scripts
viva-pygenicamPython bindings (PyO3). Own workspace, own Cargo.lockPython users
viva-genapiNodeMap, feature access, SwissKnife, selectors, access predicatesEnd-users & contributors
viva-genapi-xmlLoad GenICam XML from device or disk, parse into XmlModelContributors (XML / SFNC work)
viva-gigeGigE Vision transport: GVCP control + GVSP streamingEnd-users & contributors
viva-u3vUSB3 Vision transport over libusbEnd-users & contributors
viva-gencpGenCP message encode/decode, shared by both transportsContributors, protocol nerds
viva-pfncPixel Format Naming Convention tablesContributors
viva-sfncStandard Feature Naming Convention constantsContributors
viva-zenoh-apiMessage payloads and topic names shared with Viva Studio; plain serde types that do not link ZenohContributors
viva-serviceZenoh bridge: GigE cameras → Viva StudioIntegrators
viva-service-u3vZenoh bridge: U3V cameras → Viva StudioIntegrators
viva-fake-gigeIn-process fake GigE camera for tests and demosEveryone
viva-fake-u3vIn-process fake U3V camera for testsContributors

If you just want to use a camera from Rust, start with viva-genicam (or viva-camctl from the command line) and ignore the lower layers. Three of these have chapters of their own: viva-gige, viva-genapi and viva-gencp.


How the crates fit together

At a high level, the crates compose like this:

           ┌───────────────┐      ┌────────────────┐
           │   viva-gencp      │      │   viva-genapi  │
           │ GenCP encode  │      │ NodeMap,       │
           │ / decode      │      │ SwissKnife,    │
           └─────┬─────────┘      │ selectors      │
                 │                └──────┬─────────┘
                 │                       │
           ┌─────▼─────────┐      ┌──────▼─────────┐
           │   viva-gige     │      │  viva-genapi-xml    │
           │ GVCP / GVSP   │      │ XML loading &  │
           │ packet I/O    │      │ schema-lite IR │
           └─────┬─────────┘      └──────┬─────────┘
                 │                       │
                 └──────────┬────────────┘
                            │
                      ┌─────▼───────┐
                      │ viva-genicam│  ← public Rust API
                      └─────┬───────┘
                            │
                      ┌─────▼───────┐
                      │ viva-camctl   │  ← CLI on top of `viva-genicam`
                      └─────────────┘

Roughly:

  • viva-gige knows how to talk UDP to a GigE Vision device (discovery, register access, image packets, stats, …).
  • viva-gencp provides the GenCP building blocks used on the control path.
  • viva-genapi-xml fetches and parses the GenApi XML that describes the device’s features.
  • viva-genapi turns that XML into a NodeMap you can read/write, including SwissKnife expressions and selector-dependent features.
  • viva-genicam stitches all of the above into a reasonably ergonomic API.
  • viva-camctl exposes common workflows from genicam as cargo run -p viva-camctl -- ….

When to use which crate

I just want to use my camera from Rust

Use viva-genicam.

Typical tasks:

  • Enumerate cameras on a NIC
  • Open a device, read/write features by name
  • Start a GVSP stream, iterate over frames, look at stats
  • Subscribe to events or send action commands

Start with the examples under crates/viva-genicam/examples/ and the Tutorials.

I want a command-line tool for daily work

Use viva-camctl.

Typical tasks:

  • Discovery: list all cameras on a given interface
  • Register/feature inspection and configuration
  • Quick streaming tests and stress benchmarks
  • Enabling/disabling chunk data, configuring events

This is also a good reference for how to structure a “real” application on top of genicam.

I need to touch GigE Vision packets / low-level transport

Use viva-gige (and viva-gencp as needed).

Example reasons:

  • You want to experiment with MTU, packet delay, resend logic, or custom stats
  • You’re debugging interoperability with a weird device and need raw GVCP/GVSP
  • You want to build a non-GenApi tool that only tweaks vendor-specific registers

The viva-gige chapter goes into more detail on discovery, streaming, events, actions, and tuning.

I want to work on GenApi / XML internals

Use viva-genapi-xml and viva-genapi.

Typical contributor activities:

  • Supporting new SFNC features or vendor extensions
  • Improving SwissKnife coverage or selector handling
  • Adding tests for tricky XML from specific camera families

The following chapter is relevant:

If you’re not sure where a GenApi bug lives, the rule of thumb is:

  • “XML can’t be parsed” → genapi-xml
  • “Feature exists but behaves wrong” → viva-genapi
  • “Device returns odd data / status codes” → viva-gige or viva-gencp

I need a single high-level entry point

Use viva-genicam.

This crate aims to expose just enough control/streaming surface for most applications without making you think about transports, XML, or NodeMap internals.

The tutorials show:

  • How to go from “no camera” to “frames in memory” in ~20 lines
  • How to query and set features safely (with proper types)
  • How to plug in your own logging, error handling, and runtime

Crate deep dives

The rest of this section of the book contains crate-specific chapters:

If you’re reading this for the first time, a good path is:

  1. Skim this page.
  2. Work through the tutorials.
  3. Jump to viva-gige or viva-genapi when you hit something you want to tweak.

viva-gencp — GenCP message primitives

viva-gencp is the smallest crate in the stack: one lib.rs that encodes and decodes GenCP control messages and nothing else. No sockets, no retries, no async, no device state.

That is the point. GigE Vision carries GenCP over UDP (GVCP) and USB3 Vision carries it over bulk endpoints; the message layout, the opcodes and the status codes are the same in both. Keeping them in a transport-free crate means the two transports share one definition rather than two that drift.


What is actually in it

ItemPurpose
OpCodeReadRegister, WriteRegister, ReadMem, WriteMem
StatusCodeTransport-neutral status, with Unknown(u16) for anything not in the table
CommandHeader / AckHeaderThe 8-byte headers
GenCpCmd / GenCpAckHeader plus payload
encode_cmd / decode_ackThe two functions you call
GenCpErrorDecode failures
HEADER_SIZE, PENDING_ACK_COMMAND8 and 0x0805

OpCode::command_code() gives the wire value (0x0080, 0x0082, 0x0084, 0x0086); StatusCode::from_raw / to_raw convert the status field.


Request → acknowledge

Every command carries a request id, and the acknowledgement echoes it. The transport is responsible for matching them and for discarding stale acks — a late reply to a timed-out request must not be accepted as the answer to the next one.

Callers do not usually touch this crate. GigeDevice and U3vDevice build the commands, and NodeMap sits above those, so an application reads ExposureTime and the register transactions happen underneath.


Status codes

The table matters more than it looks, because a status is often the only thing a user sees when something fails.

CodeMeaningRetry?
SuccessCompleted
NotImplementedDevice does not implement this commandNo
InvalidParameterParameter invalid or out of rangeNo
InvalidAddressNo such address on the deviceNo
WriteProtectRegister is read-onlyNo — permanent
BadAlignmentAccess not aligned as the transport requiresNo
AccessDeniedRefused this write: a GenApi lock, or control privilege not heldOnly after fixing the cause
BusyDevice busyYes — the only one worth retrying
GenericErrorDevice reported an error with nothing more specific
Unknown(u16)Not in this table, or transport-specific; carries the raw value

Two of these were decoded wrongly until 0.3.1. 0x8004 was reported as DeviceBusy and 0x8005 as a generic error, and 0x8006 had no name at all — so a FLIR camera refusing a register write told the reporter of #45 only io error: device reported status Unknown(32774). Two codes were mislabelled and a third was unnameable; the table above is the corrected one.

The distinction between WriteProtect and AccessDenied is worth keeping in mind when debugging: the first means the register never accepts writes, the second means it would but not right now.

Pending acknowledge

GenCP lets a device say “still working” rather than answering immediately. It signals that with a command idPENDING_ACK_COMMAND, 0x0805 — not with a status code. Reading it as a status is a mistake this codebase made and corrected: it meant a device denying access got retried a hundred times and then reported as a pending-ack failure, while a genuine pending ack was never recognised.


Using it directly

You would only do this for diagnostics or a vendor escape hatch. The shape:

use viva_gencp::{StatusCode, decode_ack, encode_cmd};

let bytes = encode_cmd(&cmd);       // -> Bytes, ready for the transport
// ... the transport sends `bytes` and receives `buf` ...
let ack = decode_ack(&buf)?;        // -> GenCpAck

// `AckHeader::status` is already a decoded `StatusCode`; `from_raw`/`to_raw`
// are there for transports that need the wire value.
match ack.header.status {
    StatusCode::Success => { /* ack.payload */ }
    StatusCode::Busy => { /* the one status worth retrying */ }
    other => return Err(other.into()),
}

ack.header.request_id is what the transport matches against the request it sent, and OpCode::ack_code() (command_code() + 1) is the opcode it should have come back with.

For anything above raw diagnostics, prefer the layers that already do this correctly: viva_gige::GigeDevice::{read_register, write_register, read_mem, write_mem}, or Camera::{get, set} above them.


Endianness and alignment

GenCP is big-endian on the wire; encode_cmd and decode_ack handle that, and the structs hold host-order values. Register widths must match the address alignment, and devices do return BadAlignment when they do not — which is why that status has a name of its own rather than being folded into InvalidParameter.


Testing

Encode/decode is exactly the kind of code that should be tested against the specification, not against the parser. Fixtures derived from the parser assert that the code agrees with itself: on the pending-ack bug above, the fake and the client shared one wrong assumption and the test asserted it back. See ADR-0018 and backlog TC-04.


See also

  • viva-gige — GenCP over GVCP, plus GVSP streaming
  • viva-genapi — the NodeMap that turns feature names into these messages

viva-gige — GigE Vision transport (GVCP/GVSP)

viva-gige implements the GigE Vision transport on Windows, Linux and macOS: discovery and control over GVCP, image data over GVSP, plus interface enumeration, event and action messages, and device-timestamp mapping.

It sits below viva-genapi — this crate moves bytes, the NodeMap decides which bytes. Applications normally reach it through viva-genicam.


Module map

ModuleContents
gvcpdiscover, discover_on_interface, discover_all, force_ip, DeviceInfo, GigeDevice, GigeError
gvspPacket parsing, frame reassembly, StreamDest, StreamConfig, chunk extraction
nicIface — interface enumeration and selection
actionsend_action, ActionParams, AckSummary
messageThe event/message channel
statsStreamStats and its accumulator
timeTimeSync — device ticks to host time

Selecting the local interface

On a multi-NIC host, bind to the NIC that reaches the camera. Iface offers four ways to get one, and the difference between them has caused real bugs:

use viva_gige::nic::Iface;

Iface::from_system("eth0")?;                  // by interface name
Iface::from_ipv4("192.168.0.5".parse()?)?;    // by *host* address
Iface::from_remote_ipv4("192.168.0.10".parse()?)?; // by the *camera's* address
Iface::list()?;                               // everything the library can see

from_ipv4 takes an address on this host. from_remote_ipv4 takes the camera’s address and probes the routing table for the interface that reaches it — which is what you want when all you have is the camera’s IP. Passing a camera address to from_ipv4 is the defect that made acquisition fail on real hardware in #70: it only ever worked against the loopback fake, where the two addresses coincide.

Iface::list() is also a diagnostic. It reports interfaces as the library sees them, which is not always the set the OS shows — on Windows, link-local 169.254.x.x addresses were invisible until the link-local feature of if-addrs was enabled, and an interface missing from this list is invisible to discovery no matter what ipconfig says (#57).


Discovery (GVCP)

A broadcast command, then replies collected for a timeout window. Each reply becomes a DeviceInfo with IP, MAC, manufacturer, model, version, serial and user-defined name.

    // `discover` broadcasts on the interfaces the library can see; naming one
    // pins it to that NIC, which is what you want on a multi-homed host.
    // `IfaceSelector` takes either spelling — `--iface 192.168.0.5` or
    // `--iface eth0` — so the flag means the same thing here as in
    // `viva-camctl` and `viva-service`.
    let devices = if let Some(selector) = iface.as_ref() {
        let iface = selector.resolve()?;
        viva_genicam::gige::discover_on_interface(timeout, iface.name()).await?
    } else {
        viva_genicam::gige::discover(timeout).await?
    };

    if devices.is_empty() {
        println!("No cameras found.");
        return Ok(());
    }

    println!("{:<16} {:<17} {:<20} Model", "IP", "MAC", "Manufacturer");
    for dev in devices {
        println!(
            "{:<16} {:<17} {:<20} {}",
            dev.ip,
            format_mac(&dev.mac),
            dev.manufacturer.as_deref().unwrap_or("-"),
            dev.model.as_deref().unwrap_or("-"),
        );
    }
FunctionScans
discover(timeout)Every routable interface
discover_on_interface(timeout, name)One named interface
discover_all(timeout)Every interface including loopback

Use discover_all only for the fake camera. One unusable interface no longer aborts the whole call, and a stray non-GVCP packet on the port no longer discards the replies already collected — both were real failure modes.

From the CLI:

cargo run -p viva-camctl -- list --iface 192.168.0.5

Control (GVCP)

GigeDevice owns one control channel:

use viva_gige::gvcp::{GigeDevice, GVCP_PORT};

let mut device = GigeDevice::open(SocketAddr::new(camera_ip.into(), GVCP_PORT)).await?;
device.claim_control().await?;

let value = device.read_register(0x0a00).await?;
device.write_register(0x0a00, value | 1).await?;

let bytes = device.read_mem(0x0200, 512).await?;

read_register/write_register are 32-bit at a 32-bit address; read_mem/write_mem take a 64-bit address and a length, and chunk the transfer to fit the transport.

Control privilege and the heartbeat

claim_control() takes the Control Channel Privilege. A device revokes it if no GVCP command arrives within GevHeartbeatTimeout — 3 000 ms is typical — and GVSP image traffic does not count towards that timer. A camera can therefore be streaming at full rate while the control channel times out, and the next write fails with AccessDenied.

You do not have to manage this. GigeRegisterIo in viva-genicam owns a keepalive: it reads the device’s own GevHeartbeatTimeout and pings at a quarter of it, so holding a Camera is enough. heartbeat_timeout_ms() and ping_control_channel() are here for anyone driving GigeDevice directly.

IP configuration

force_ip assigns a temporary address to a camera identified by MAC — useful when a camera is on the wrong subnet and otherwise unreachable. write_persistent_ip and enable_persistent_ip make it survive a power cycle.

cargo run -p viva-camctl -- set-ip --mac DE:AD:BE:EF:CA:FE --ip 192.168.1.100 --force

Events and actions

  • Events are device-to-host notifications on the message channel (exposure end, and vendor-defined ones). set_message_destination points the device at a host socket; viva_genicam::EventStream presents the result.
  • Actions are host-to-many-devices: send_action broadcasts an action command so several cameras trigger together, optionally at a scheduled timestamp. AckSummary reports which devices acknowledged.

Both are vendor-variable. If you schedule actions, keep the time bases consistent — see TimeSync below.


Streaming (GVSP)

The receiver negotiates stream parameters on the control channel, then receives UDP packets and reassembles frames by block ID.

Application code builds streams through viva-genicam, not this crate directly:

    // Connect to camera (fetches XML, builds nodemap).
    let mut camera = connect_gige(&device).await?;

    // Configure stream.
    let mut stream_device = viva_genicam::gige::GigeDevice::open(std::net::SocketAddr::new(
        std::net::IpAddr::V4(device.ip),
        viva_genicam::gige::GVCP_PORT,
    ))
    .await?;
    let mut builder = StreamBuilder::new(&mut stream_device).iface(iface.clone());
    if let Some(group) = args.multicast {
        builder = builder.multicast(Some(group));
    }
    if let Some(port) = args.port {
        builder = builder.destination_port(port);
    }
    if let Some(size) = args.packet_size {
        builder = builder.packet_size(size);
    } else {
        builder = builder.auto_packet_size();
    }
    let stream = builder.build().await?;

    // Create high-level frame stream (handles packet reassembly automatically).
    let time_sync = camera.time_sync().clone();
    let mut frame_stream = FrameStream::new(stream, Some(time_sync));

    // Start acquisition.
    camera.acquisition_start()?;

StreamBuilder (in viva_genicam::stream) exposes iface, dest, target_mtu, packet_size, packet_delay, destination_port, multicast, rcvbuf_bytes and channel. FrameStream wraps the result and yields whole frames.

Packet size and MTU

GevSCPSPacketSize is the size of the transmitted IP packet, so it must fit the path MTU end to end. The probed MTU is used unless packet_size overrides it. Two caveats worth knowing:

  • Cameras clamp a size they cannot honour, and the write succeeds when they do. StreamBuilder::build reads the register back through GigeDevice::get_stream_packet_size and puts the effective size in StreamParams, so reassembly follows the camera rather than the request. A device that will not answer the read-back keeps the requested value and logs a warning.
  • On a large-MTU link the requested size must still be clamped to the IPv4 maximum. Linux loopback reports MTU 65536, which would produce a 65 508-byte datagram against the 65 507-byte limit — every send_to fails. An explicitly configured size above 65 535 is refused rather than truncated: GevSCPSPacketSize holds the size in 16 bits, so writing 70 000 would configure 4 464.

Resend

GVSP defines packet resend, and the pieces exist here — ResendPlanner, coalesce_missing, GigeDevice::request_resend. They are not wired into the receive path (backlog SR-04). Nothing in a live stream requests a resend, and nothing increments the resends counter, so a summary reading resends=0 means “not implemented” rather than “none were needed”. drops is the number to watch. This section changes when resend lands.

Chunk data

With ChunkModeActive set, the payload carries the image followed by chunk blocks ([id][reserved][length][data]). parse_chunks extracts them and skips what it does not recognise; viva_genicam::ChunkMap maps the known ones (timestamp, exposure, gain) to typed values.

Statistics

StreamStats carries frames, bytes, drops, packets, avg_fps, avg_mbps, avg_latency_ms and the elapsed window. The resend and backpressure counters are inert for the reason above.


Timestamp mapping

Devices report a tick counter, not wall-clock time. TimeSync maintains a linear mapping from device ticks to host SystemTime, calibrated by latching the device timestamp against a host reading. Without that calibration there is no origin to map from — so treat an uncalibrated host timestamp as absent rather than as data.


Logging

RUST_LOG=info,viva_gige=debug cargo run -p viva-camctl -- stream --ip 192.168.0.10

viva-camctl maps -v to debug and -vv to trace if you would rather not set the variable. Useful targets: viva_gige::gvcp (binds, discovery, register ops), viva_gige::gvsp (packets, reassembly, frame stats), viva_gige::nic (interface enumeration and socket binding).


Platform notes

Windows. Allow inbound UDP for discovery and the stream port, for both the Private and Public firewall profiles. Enable jumbo frames in the NIC’s advanced settings if the whole path supports them, and keep the power plan on high performance — receive buffers default low on many desktop NICs.

Linux. With firewalld, GVCP replies arrive from source port 3956 and the GVSP port needs its own rule — see Link-local (APIPA) cameras. net.core.rmem_max caps how far rcvbuf_bytes can go.

Link-local. GigE Vision cameras fall back to 169.254.0.0/16 when no DHCP server answers. That works, but the host needs an address in the same range and the firewall usually needs telling — the same networking chapter covers it.


See also

viva-genapi — the NodeMap

viva-genapi turns the XmlModel produced by viva-genapi-xml into a NodeMap: the thing that knows what ExposureTime means, where it lives, what it depends on, and whether you are allowed to write it right now.

It has no transport of its own. Every accessor takes a &dyn RegisterIo, which is the whole coupling between this crate and the wire:

pub trait RegisterIo {
    fn read(&self, addr: u64, len: usize) -> Result<Vec<u8>, GenApiError>;
    fn write(&self, addr: u64, data: &[u8]) -> Result<(), GenApiError>;
}

Two implementations ship with the stack: GigeRegisterIo and U3vRegisterIo in viva-genicam, and NullIo here — which returns zeroes for reads and discards writes. NullIo is not a placeholder: it is how a GenApi document is browsed with no camera attached, which is what Viva Studio and the WASM build do. Values that depend only on the XML — SwissKnife expressions over constants, enum entry lists, ranges, the category tree — come out correct; anything backed by a real register comes out zero, so read structure from it, not data.


Node kinds

VariantGenICam name
Node::IntegerInteger, IntReg, MaskedIntReg
Node::FloatFloat, FloatReg
Node::EnumEnumeration
Node::BooleanBoolean
Node::CommandCommand
Node::CategoryCategory
Node::SwissKnifeSwissKnife, IntSwissKnife
Node::ConverterConverter
Node::IntConverterIntConverter
Node::StringStringReg
Node::RegisterRegister (plain <Length> only; <pLength> is not supported yet)

Node::kind_name() returns the GenICam name, name() the feature name, and access_mode() the declared mode — which is not the same as the effective one, see below.


Reading and writing

The accessors are typed, and each takes the transport:

let width  = nodemap.get_integer("Width", &io)?;
let expo   = nodemap.get_float("ExposureTime", &io)?;
let fmt    = nodemap.get_enum("PixelFormat", &io)?;
let on     = nodemap.get_bool("ReverseX", &io)?;
let serial = nodemap.get_string("DeviceSerialNumber", &io)?;

nodemap.set_integer("Width", 640, &io)?;
nodemap.exec_command("AcquisitionStart", &io)?;

Camera in viva-genicam wraps these behind string-valued get/set, which is what most application code should use — see Registers & features.

Addressing

A node’s address is not always a constant. It can be:

  • Fixed — an <Address> in the XML.
  • Computed — a sum of <Address>, <pAddress> (another node’s value) and <Integer> offsets, resolved at access time.
  • Delegated<pValue> pointing at another node, which may itself delegate. The declaration you are reading is often not the one holding the data.

This matters because it is where the interesting bugs live. A parser can accept a document perfectly and the address model still be wrong, which is why the vendor corpus test does not stop at parsing: it builds a NodeMap from each real document and evaluates every node.

Selectors

When a feature is selector-dependent (Gain behind GainSelector), the selector’s current value takes part in resolving the address. Writing the selector invalidates the cached values of everything that depends on it, so the next read of Gain returns the newly selected channel rather than a stale value.

SwissKnife and Converter

SwissKnife nodes compute a value from other nodes with a formula — arithmetic, comparisons, bitwise operators, and a ternary. Converter and IntConverter run a formula in both directions, so they are writable: the FROM expression maps a user value back to the raw one. Evaluation is transparent — you call get_float and the inputs are read or computed first.


Access mode is evaluated, not declared

AccessMode in the XML is a starting point. The effective mode also depends on predicates the device answers at runtime:

  • pIsImplemented — the feature is absent on this model.
  • pIsAvailable — present but not currently applicable.
  • pIsLocked — writable in principle, locked right now.

effective_access_mode, is_implemented, is_available and available_enum_entries expose this. Writes go through it: a locked node is refused locally, with GenApiError::Locked { name, locked_by } naming the feature that holds the lock — because “access denied” leaves a caller nowhere to go, whereas “ExposureTime is locked by ExposureTime_Lck” says what to change first.


Introspection

Built for consumers that need to render a feature tree rather than read one value:

MethodReturns
node_names()Every feature name
node(name)The Node, or None
categories()Category name → its members
dependents(name)Which nodes are invalidated when this one changes
nodes_at_visibility(level)Beginner / Expert / Guru / Invisible filtering
version()The document’s schema version
skipped()Nodes that could not be built

skipped() is the important one. A construct this crate cannot handle no longer fails the whole document — it lands here, and the parser’s own losses (XmlModel::skipped) are carried along with it, so a consumer sees both. Before that, a single unhandled node made a camera unopenable: that is exactly what #35 and #45 were.


Caching and invalidation

Values are cached and invalidated by dependency: writing a node clears the cached values of everything dependents() lists for it, including through pValue delegation and selector relationships. Cache correctness is a conformance question, not an optimisation — a stale Width after a selector change is a wrong answer, not a slow one.


Errors

GenApiError is specific on purpose; the variant usually says what to do next:

VariantMeans
NodeNotFound(name)No such feature in this camera’s document
Type(name)Asked for the wrong type — get_float on an Integer
Access(name)The node’s access mode forbids the operation
Locked { name, locked_by }pIsLocked is engaged; locked_by is the feature to change
Range(name)Value outside Min/Max, or off Inc
Unavailable(name)Hidden by the current selector state
Io(msg)The transport failed
Parse(msg)Metadata or conversion failure
ExprParse / ExprEval / UnknownVariableA SwissKnife formula failed to parse, evaluate, or resolve a reference
EnumValueUnknown / EnumNoSuchEntryRaw value maps to no entry, or no entry has that name
BadIndirectAddresspAddress resolved to something impossible
BitfieldOutOfRange / ValueTooWideBitfield metadata exceeds the register, or the value exceeds the field

For contributors

  • Keep evaluation pure and reach the device only through RegisterIo. That separation is what makes NullIo and the corpus test possible.
  • New node kinds go behind the same evaluation path, and must land in skipped() rather than aborting the document when something is unsupported.
  • Test against the specification, not against our own parser. When the two disagree, the specification wins unless real hardware says otherwise — ADR-0018 lists eight defects that each looked reasonable in isolation.

See also

Tutorials

This section walks you through typical workflows step by step.

The focus is a GigE Vision camera accessed over Ethernet, using:

  • The viva-camctl CLI for quick experiments and ops work.
  • The viva-genicam crate for Rust examples you can copy into your own code.

If you haven’t done so yet, first read:

They explain how to build the workspace and verify that your toolchain works.


If you are new to the project, the recommended reading order is:

  1. Discovery
    Find cameras on your network, verify that discovery works, and understand basic NIC and firewall requirements.

  2. Registers & features
    Read and write GenApi features (e.g. ExposureTime), understand selectors such as GainSelector, and learn when you might need raw registers.

  3. GenApi XML
    Fetch the GenICam XML from a device, inspect it, and see how it maps to the NodeMap used by viva-genapi.

  4. Streaming
    Start a GVSP stream, receive frames, look at stats, and learn which knobs matter for throughput and robustness.

You can stop after Discovery and Streaming if you only need to verify that your camera works. The other tutorials are useful when you want to build a full application or debug deeper GenApi issues.


What you need before starting

Before running any tutorial, make sure you have:

  • A working Rust toolchain (see rust-toolchain.toml for the pinned version).

  • The workspace builds successfully:

    cargo build --workspace
    
      •	At least one GigE Vision camera reachable from your machine:
      •	Either directly connected to a NIC.
      •	Or via a switch on a dedicated subnet.
    
    

For networking details (MTU, jumbo frames, Windows specifics, etc.), see Networking once that chapter is filled in.

Tutorials overview • Discovery Use viva-camctl and the genicam examples to find cameras and verify that basic communication is working. • Registers & features Use features by name, work with selectors, and know when to fall back to raw register access. • GenApi XML Fetch XML from the device, inspect it, and understand how genapi-xml and viva-genapi use it. • Streaming Start streaming, tune packet size and delay, and interpret statistics and logging output.

Each tutorial has: • A CLI variant using viva-camctl. • A Rust variant using the viva-genicam crate and its examples.


Discovery

Goal of this tutorial:

  • Verify that your host can see your GigE Vision camera.
  • Run discovery from the viva-camctl CLI and from Rust.
  • Understand the usual reasons it fails (NIC selection, firewall, subnets).

If discovery does not work, the other tutorials will not help much — fix this first.


Before you begin

Make sure that:

  • The workspace builds:

    cargo build --workspace
    
  • Your camera and host are physically connected — a direct cable from host NIC to camera, or a switch on a subnet dedicated to the cameras.

  • The camera has a valid IPv4 address: from DHCP on your camera network, a static address matching the host NIC’s subnet, or a link-local (APIPA) address. Link-local setups need a little extra care on the host — see Link-local (APIPA) cameras.

For jumbo frames, MTU and throughput tuning, see the Networking Guide.


Step 1 – Discover with viva-camctl

1.1. Basic discovery

cargo run -p viva-camctl -- list

On success you get one line per device with its IP, MAC, manufacturer and model. If nothing appears:

  • Check that the camera is powered and the link LED is lit.
  • Check that your NIC is on the same subnet as the camera.
  • Check that your host firewall allows UDP broadcast on that NIC.

1.2. Selecting an interface explicitly

On multi-NIC systems, tell viva-camctl which interface to use. --iface names your host NIC — never the camera — and accepts either of the two ways a host NIC is normally identified:

cargo run -p viva-camctl -- list --iface 192.168.0.5   # one of its IPv4 addresses
cargo run -p viva-camctl -- list --iface eth0          # its OS name

Both spellings work in viva-service, in the Python iface= argument and in the Rust examples too, so the value you found once carries across the tools. Use whichever you have: on Windows an interface name is a GUID like {6394C55F-F630-4BC7-92D2-7AC320C73D1C}, which is far harder to obtain than the address.

If you are not sure which NIC to use, ip addr (Linux), ifconfig (macOS) or ipconfig (Windows) will tell you — and so will an unresolvable --iface, which lists every interface the library can see. That list matters on its own: it is not always the same set the OS reports, and an interface missing from it is invisible to discovery no matter what anything else says.

If discovery works with --iface but not without it, your machine has several active interfaces and the automatic choice is not the one you expect.

1.3. Machine-readable output

--json is a top-level flag, so it goes before the subcommand:

cargo run -p viva-camctl -- --json list

Step 2 – Discover from Rust

cargo run -p viva-genicam --example list_cameras
cargo run -p viva-genicam --example list_cameras -- --iface eth0
cargo run -p viva-genicam --example list_cameras -- --iface 192.168.0.5

The part that matters is short:

    // `discover` broadcasts on the interfaces the library can see; naming one
    // pins it to that NIC, which is what you want on a multi-homed host.
    // `IfaceSelector` takes either spelling — `--iface 192.168.0.5` or
    // `--iface eth0` — so the flag means the same thing here as in
    // `viva-camctl` and `viva-service`.
    let devices = if let Some(selector) = iface.as_ref() {
        let iface = selector.resolve()?;
        viva_genicam::gige::discover_on_interface(timeout, iface.name()).await?
    } else {
        viva_genicam::gige::discover(timeout).await?
    };

    if devices.is_empty() {
        println!("No cameras found.");
        return Ok(());
    }

    println!("{:<16} {:<17} {:<20} Model", "IP", "MAC", "Manufacturer");
    for dev in devices {
        println!(
            "{:<16} {:<17} {:<20} {}",
            dev.ip,
            format_mac(&dev.mac),
            dev.manufacturer.as_deref().unwrap_or("-"),
            dev.model.as_deref().unwrap_or("-"),
        );
    }

Three entry points exist, and the difference matters more than it looks:

FunctionScans
gige::discover(timeout)Every routable interface the library can enumerate
gige::discover_on_interface(timeout, name)One named interface
gige::discover_all(timeout)Every interface including loopback

Use discover_all only when you are talking to the fake camera on 127.0.0.1. Against real hardware it adds a loopback scan that can only produce noise.


Step 3 – Interpreting results

Record two things — you will reuse them in every later tutorial:

  • The camera’s IP address (e.g. 192.168.0.10) → --ip 192.168.0.10
  • The host NIC you used (e.g. 192.168.0.5 or eth0) → --iface 192.168.0.5

If several cameras answer, label them physically now rather than guessing later.


Troubleshooting checklist

If neither viva-camctl list nor list_cameras finds anything:

  1. Physical link — is the link LED lit on NIC, switch and camera? Try another cable or port.
  2. Subnets — host NIC and camera must share a subnet. Two NICs on the same subnet confuse routing; avoid it.
  3. Firewall — allow UDP broadcast on the camera NIC. On Windows the executable must be permitted for both “Private” and “Public” profiles. On Linux with firewalld, GVCP replies arrive from source port 3956 and need an explicit rule; see Letting the reply back in.
  4. Multiple NICs — force the right one with --iface <host-ip>, or disable the others temporarily to confirm that NIC selection is the problem.
  5. Vendor tools — if the vendor’s viewer sees the camera and viva-camctl does not, compare which NIC and IP the vendor tool uses, and check whether it reconfigured the camera’s address (DHCP, or a “force IP” button).

Still failing? Capture the details and send them:

cargo run -p viva-camctl -- report --out viva-report.txt

The report records your interfaces as the library sees them and everything discovery did or did not hear — which is precisely what we need and cannot guess. See Reporting a camera we can’t open.

Registers & features

Goal of this tutorial:

  • Read and write GenApi features such as ExposureTime or Gain.
  • Understand how features map to the underlying registers.
  • Use selectors (e.g. GainSelector) and understand what they change.
  • Do all of it from both the viva-camctl CLI and Rust.

Work through Discovery first, so you know your camera’s IP and which host interface you are using.


Concepts: features vs registers

GenICam describes camera configuration as features in the GenApi XML:

  • A feature has a name (ExposureTime, Gain, PixelFormat, …) and a type (Integer, Float, Boolean, Enumeration, Command, String, …).
  • Under the hood, a feature usually corresponds to one or more registers. A simple one reads a single 32-bit register; others are derived through SwissKnife expressions, or depend on selectors.

The layering:

  • viva-genapi-xml parses the XML into an XmlModel.
  • viva-genapi builds a NodeMap from it and evaluates nodes on demand.
  • viva-genicam and viva-camctl sit on top and hide the addressing.

Step 1 – Inspect features with viva-camctl

You need the camera IP from the discovery tutorial, and the host interface IP if you have several NICs.

1.1. Read a feature by name

cargo run -p viva-camctl -- get --ip 192.168.0.10 --name ExposureTime

For machine-readable output, note that --json is a top-level flag and goes before the subcommand:

cargo run -p viva-camctl -- --json get --ip 192.168.0.10 --name ExposureTime

1.2. Write a feature by name

cargo run -p viva-camctl -- set --ip 192.168.0.10 --name ExposureTime --value 5000
cargo run -p viva-camctl -- get --ip 192.168.0.10 --name ExposureTime

If the value does not change, the usual causes are:

  • The feature is locked right now. GenApi expresses this with pIsLocked, which the library evaluates before every write — so a locked feature is refused locally with a clear error, rather than being sent to the camera and failing there.
  • The value violates a constraint (range, increment, alignment).
  • Another feature is overriding manual control — ExposureAuto is the usual culprit for ExposureTime, GainAuto for Gain.

1.3. Which features does this camera have?

The report bundle lists node and feature counts along with the XML itself:

cargo run -p viva-camctl -- report --ip 192.168.0.10 --out viva-report.txt

Step 2 – Work with selectors

Many cameras multiplex several logical settings onto the same registers:

  • GainSelector = All, Red, Green, Blue, …
  • Gain = the value for the currently selected channel.

Changing the selector changes which “row” you are editing. The NodeMap re-resolves the addressing and invalidates the cached values that depended on it, so a read after a selector write returns the new channel’s value rather than a stale one.

# What can the selector be set to?
cargo run -p viva-camctl -- --json get --ip 192.168.0.10 --name GainSelector

# Different gain per channel
cargo run -p viva-camctl -- set --ip 192.168.0.10 --name GainSelector --value Red
cargo run -p viva-camctl -- set --ip 192.168.0.10 --name Gain --value 5.0

cargo run -p viva-camctl -- set --ip 192.168.0.10 --name GainSelector --value Blue
cargo run -p viva-camctl -- set --ip 192.168.0.10 --name Gain --value 3.0

The selectors_demo example shows the same pattern in Rust:

cargo run -p viva-genicam --example selectors_demo

Step 3 – Do the same from Rust

cargo run -p viva-genicam --example get_set_feature
cargo run -p viva-genicam --example get_set_feature -- --name Gain --value 3.0

The whole of it:

    // `connect_gige` fetches the GenApi XML and builds the NodeMap, so every
    // feature the camera declares is addressable by name from here on.
    let mut camera = connect_gige(&device).await?;

    // Feature access is synchronous even inside an async program: the register
    // I/O behind it blocks, and `GigeRegisterIo` steps off the async worker on
    // its own rather than making every caller do it.
    println!("{name} = {}", camera.get(&name)?);

    if let Some(value) = value.as_deref() {
        camera.set(&name, value)?;
        println!("{name} = {} (after write)", camera.get(&name)?);
    }

Two things are worth pointing out.

Values are strings at this boundary. Camera::get returns String and Camera::set takes &str; the node’s own type decides how that text is parsed and encoded. set_exposure_time_us and set_gain_db are typed conveniences over the two most common cases.

Feature access is synchronous, including inside #[tokio::main]. The register I/O behind it blocks, and GigeRegisterIo steps off the async worker by itself — you do not need to wrap calls in spawn_blocking.

If you have no camera to hand, the same calls work against the fake camera:

cargo run -p viva-genicam --example demo_fake_camera
    // `get` and `set` are synchronous. They block on register I/O, and
    // `GigeRegisterIo` steps off the async worker itself, so no `spawn_blocking`
    // wrapper is needed even here inside `#[tokio::main]`.
    println!("Reading camera features:");
    for feature in [
        "Width",
        "Height",
        "PixelFormat",
        "ExposureTime",
        "Gain",
        "GevTimestampTickFrequency",
    ] {
        match camera.get(feature) {
            Ok(value) => println!("  {feature} = {value}"),
            Err(err) => println!("  {feature} = <error: {err}>"),
        }
    }
    println!();

    // ── 5. Write a feature ──────────────────────────────────────────────────
    println!("Setting Width = 320, ExposureTime = 10000 ...");
    camera.set("Width", "320")?;
    camera.set_exposure_time_us(10_000.0)?;
    println!("  Width readback = {}\n", camera.get("Width")?);

Step 4 – When you might need raw register access

Prefer features by name. You get the node’s type, you respect the vendor’s declared constraints, and your code stays portable across cameras.

Raw registers are still occasionally the right tool:

  • Debugging unusual vendor behaviour or firmware bugs.
  • Reaching something genuinely absent from the XML.
  • Bringing up a device whose GenApi description is incomplete.

viva-gige and viva-gencp expose the primitives — see the viva-gige and viva-gencp chapters. Be careful: writing arbitrary registers can leave a device unusable until it is power-cycled.


Recap

You should now be able to:

  • Read and write features by name, from the CLI and from Rust.
  • Recognise why a write was refused — a lock, a constraint, or an auto feature.
  • Use selectors to address per-channel settings.
  • Know that raw register access exists and is a last resort.

Next: GenApi XML — where the feature list comes from in the first place.

GenApi XML

Goal of this tutorial:

  • Understand what the GenICam XML is and where it lives.
  • See how viva-genapi-xml fetches it from the device and parses it.
  • Fetch it yourself, from the CLI and from Rust.
  • Know when you actually need to look at it.

You should already have worked through Discovery and Registers & features.


1. What is the GenICam XML?

Every GenICam-compliant device carries a self-description document:

  • It lists every feature the device supports — name, type, access mode, range.
  • It defines how those features map to device registers.
  • It encodes categories, selectors, and SwissKnife expressions.
  • It declares which GenApi schema version the document uses.

The document normally lives in the device’s non-volatile memory. To get it, the host:

  1. Reads the GevFirstURL register at 0x0200.
  2. Interprets the result as a URL saying where the document actually is — usually local: plus a memory address and length, in principle also http:// or file://.
  3. Reads those bytes.
  4. Hands the string to a GenApi implementation.

Two details that the specification permits and real devices use:

  • If GevFirstURL is empty or its document cannot be retrieved, GevSecondURL at 0x0400 is tried next.
  • The document is often ZIP-compressed. viva-genapi-xml decompresses it transparently, subject to a 64 MiB cap so a malformed length field cannot exhaust memory.

2. The shape of the API

viva-genapi-xml exposes three things you are likely to call:

// Follow the URL registers and return the document.
pub async fn fetch_and_load_xml<F, Fut>(read_mem: F) -> Result<String, XmlError>
where
    F: FnMut(u64, usize) -> Fut,
    Fut: Future<Output = Result<Vec<u8>, XmlError>>;

// Cheap, deliberately lossy: schema version and top-level names.
pub fn parse_into_minimal_nodes(xml: &str) -> Result<MinimalXmlInfo, XmlError>;

// The full parse: every node declaration, plus the ones that had to be skipped.
pub fn parse(xml: &str) -> Result<XmlModel, XmlError>;

Application code rarely calls these directly — connect_gige does it for you. They matter when you are debugging why a feature behaves as it does, inspecting how a vendor encoded something, or adding support for a construct viva-genapi does not handle yet.


3. Getting the XML

3.1. From the command line

cargo run -p viva-camctl -- xml --ip 192.168.0.10 --out camera.xml

This stops before the nodemap is built, so it works on a camera the library cannot open — which is the only camera anyone ever needs it for. If you are reporting a problem, send this: see Reporting a camera we can’t open.

3.2. From Rust

fetch_and_load_xml knows nothing about GVCP, sockets or cameras. It calls a closure with (address, length) and expects bytes back, so any transport that can read device memory can drive it:

    // `fetch_and_load_xml` knows nothing about GVCP, sockets or cameras. It
    // asks for `(address, length)` and expects bytes back, so any transport
    // that can read device memory can supply the closure.
    let xml = {
        let cam = Arc::clone(&camera);
        viva_genapi_xml::fetch_and_load_xml(move |address, length| {
            let cam = Arc::clone(&cam);
            async move {
                let mut guard = cam.lock().await;
                guard
                    .read_mem(address, length)
                    .await
                    .map_err(|err| XmlError::Transport(err.to_string()))
            }
        })
        .await?
    };

Run it with:

cargo run -p viva-genicam --example fetch_xml

4. Inspecting the document

parse_into_minimal_nodes answers the cheap questions — which schema version, what is at the top level, does this look broken at all:

    // A deliberately lossy parse: enough to answer "which schema is this, and
    // what is at the top level", robust to node types we do not yet handle.
    let meta = viva_genapi_xml::parse_into_minimal_nodes(&xml)?;
    if let Some(version) = meta.schema_version.as_deref() {
        println!("Schema version: {version}");
    }
    println!("Top level features ({}):", meta.top_level_features.len());
    for feature in meta.top_level_features.iter().take(8) {
        println!("  - {feature}");
    }

It is intentionally lossy. It does not understand every node type; its job is to be fast and to survive schema extensions that are not implemented yet.

parse is the full path, and it is what NodeMap is built from. It returns an XmlModel with a flat list of node declarations carrying:

  • Feature name and type (Integer, Float, Enumeration, Boolean, Command, Category, SwissKnife, Converter, …).
  • Addressing: fixed, selector-based, or indirect through pAddress.
  • Access mode, bitfield layout and byte order.
  • Selector relationships and expression text.

Skipped nodes

A construct the parser cannot handle no longer fails the whole document — it goes into XmlModel::skipped, and the corresponding GenApi-level list is NodeMap::skipped(). Both are logged.

This matters because a single unhandled construct used to make a camera unopenable — that is exactly what #35 and #45 were. Degrading to “this one feature is missing” is far better than “this camera does not work”, and the corpus tests fail on any skip that is not on their allowlist, so new gaps surface rather than accumulate.


5. From XML to a NodeMap

viva-genapi takes the XmlModel and:

  • Instantiates a NodeMap.
  • Resolves feature dependencies, pValue delegation, selectors and expressions at access time rather than at load time.
  • Invalidates cached values when something they depend on changes.

You do not do this plumbing yourself in an application: connect_gige fetches, parses and builds, and viva-camctl get / set use the same pipeline. See the viva-genapi chapter for the internals.


6. When should you look at the XML?

Most of the time, treat it as an implementation detail. Crack it open when:

  • A feature behaves differently from what SFNC describes.
  • Selectors are not doing what you expect.
  • You hit a SwissKnife or bitfield corner case.
  • You are adding support for a vendor-specific wrinkle.

A workable order: dump it with viva-camctl xml, run fetch_xml for the schema version and skip list, then read the document itself in an XML viewer for the category you care about.

If you have a camera the library cannot open, that document is the single most useful thing you can send us — see Reporting a camera we can’t open.


7. Recap

You should now:

  • Know what the GenICam XML is, where it lives, and how the URL registers point at it.
  • Be able to fetch it with viva-camctl xml or fetch_and_load_xml.
  • Know the difference between the minimal scan and the full parse, and what a skipped node means.

Next: Streaming — getting image data out, now that you know how the camera describes itself.

Streaming

Goal of this tutorial:

  • Start a GVSP stream, from the CLI and from Rust.
  • Read the statistics the stream reports — and know which of them mean something today.
  • Understand the knobs that decide whether streaming is stable: packet size and MTU, packet delay, and where the current implementation stops.

You should already know your camera’s IP and the host NIC you reach it on (Discovery), and be able to set features (Registers & features).


1. How GVSP streaming works

  1. On the control path (GVCP), the host configures the stream: destination IP and port, packet size, and the acquisition settings themselves.
  2. On AcquisitionStart, the camera sends GVSP packets on the stream channel — a leader, the payload packets, and a trailer per frame.
  3. The host reassembles them into frames and reports statistics.

viva-gige owns packet handling; viva-genicam presents StreamBuilder and FrameStream on top; viva-camctl stream is a thin CLI over that.

Note that the stream channel is UDP to a different port than the control channel, and that the camera chooses when to send. A firewall that permits GVCP and not GVSP produces the confusing case where features work perfectly and no image ever arrives.


2. Streaming with viva-camctl

cargo run -p viva-camctl -- stream --help

2.1. A basic stream

cargo run -p viva-camctl -- stream \
  --ip 192.168.0.10 --iface 192.168.0.5 --duration-s 10

--iface names the host NIC, either by one of its IPv4 addresses or by its OS name (eth0, or a GUID on Windows) — and it is optional: omit it and the OS is asked which interface routes to --ip. Without --duration-s the stream runs until Ctrl+C.

Once a second you get a progress line, and a summary at the end:

[stream] fps=30.0 Mbps=73.73 frames=30 drops=0 resends=0
Summary: frames=300 bytes=92160000 drops=0 resends=0 avg_fps=30.0 avg_mbps=73.73

If no frames arrive at all:

  • Confirm nothing else is already consuming the stream (a vendor viewer holds the control channel exclusively).
  • Confirm the host firewall permits inbound UDP on the stream port — 10040 by default, changeable with --port.
  • Confirm the --iface interface is one the camera can actually reach.

2.2. Saving frames

--save N writes the first N frames to the current directory as frame_0001.pgm (Mono8) or frame_0001.ppm (anything else, and always with --rgb). Both are plain NetPBM, readable by ImageJ, GIMP, OpenCV and Pillow without a decoder.

cargo run -p viva-camctl -- stream \
  --ip 192.168.0.10 --iface 192.168.0.5 --duration-s 5 --save 3

2.3. Multicast

cargo run -p viva-camctl -- stream \
  --ip 192.168.0.10 --iface 192.168.0.5 \
  --mode multicast --group 239.192.0.10

3. Streaming from Rust

cargo run -p viva-genicam --example grab_gige -- --ip 192.168.0.10 --iface eth0

Setup — connect, build the stream, start acquisition:

    // Connect to camera (fetches XML, builds nodemap).
    let mut camera = connect_gige(&device).await?;

    // Configure stream.
    let mut stream_device = viva_genicam::gige::GigeDevice::open(std::net::SocketAddr::new(
        std::net::IpAddr::V4(device.ip),
        viva_genicam::gige::GVCP_PORT,
    ))
    .await?;
    let mut builder = StreamBuilder::new(&mut stream_device).iface(iface.clone());
    if let Some(group) = args.multicast {
        builder = builder.multicast(Some(group));
    }
    if let Some(port) = args.port {
        builder = builder.destination_port(port);
    }
    if let Some(size) = args.packet_size {
        builder = builder.packet_size(size);
    } else {
        builder = builder.auto_packet_size();
    }
    let stream = builder.build().await?;

    // Create high-level frame stream (handles packet reassembly automatically).
    let time_sync = camera.time_sync().clone();
    let mut frame_stream = FrameStream::new(stream, Some(time_sync));

    // Start acquisition.
    camera.acquisition_start()?;

Then the loop. Packet reassembly, ordering and buffering all stay inside FrameStream; what you get is whole frames:

    // `next_frame` yields a fully reassembled frame; packet-level bookkeeping
    // stays inside `FrameStream`. `None` means the stream ended.
    while let Some(frame) = frame_stream.next_frame().await? {
        frame_index += 1;
        print_frame_info(frame_index, &frame);

        if save_remaining > 0 {
            match save_frame(&frame, frame_index, args.rgb) {
                Ok(path) => println!("  saved {}", path.display()),
                Err(err) => warn!(error = %err, "failed to save frame"),
            }
            save_remaining = save_remaining.saturating_sub(1);
        }

        // Print stats overlay every second.
        if last_overlay.elapsed() >= Duration::from_secs(1) {
            print_overlay(&stats.snapshot());
            last_overlay = Instant::now();
        }

        // Stop after saving requested number of frames.
        if frame_index >= args.save {
            break;
        }
    }

    camera.acquisition_stop()?;

Two things to note. The stream is built on a second GigeDevice rather than the one inside Camera, because stream setup writes to the same device while the camera holds it. And acquisition_start / acquisition_stop are ordinary synchronous calls — see Registers & features.

No camera? The fake camera streams over loopback:

cargo run -p viva-genicam --example demo_fake_camera

4. Tuning for stability

4.1. Packet size and MTU

The single most important setting. GevSCPSPacketSize is the size of the transmitted IP packet, so it must fit the path MTU end to end — camera, every switch, and the host NIC.

  • Too large: packets are fragmented or silently dropped, and frames arrive incomplete.
  • Too small: more packets per frame, more per-packet overhead, and the host CPU becomes the bottleneck sooner.

The usual approach is jumbo frames (MTU 9000) on a dedicated camera network, with the packet size set just below the path MTU — every hop, not only the host NIC. A NIC that advertises 16114 and a switch that only forwards ~9216-byte frames will accept a write of 16114 at both ends and still deliver nothing (Vieworks FS-3200T through an ipTIME PoE4002; the same camera direct to the NIC streams up to 16114).

What the library does (ADR-0021 / SR-14). Default preserves the camera’s current GevSCPSPacketSize: it is read, never raised. Pass --auto / StreamBuilder::auto_packet_size() to set it from the host NIC MTU instead, or --packet-size N / StreamBuilder::packet_size(n) for an explicit ceiling (mutually exclusive with --auto). A clamping camera is followed on write (SR-02).

That overwrite-on-every-Start behaviour from 0.4.x is gone: a camera already set for a narrower switch keeps that value unless you opt into auto or an explicit size.

All three then probe the path (SR-13): the library asks the camera for a GVSP test packet and bisects downward when the size does not arrive. The probe only ever lowers, so it cannot override a size you chose — it can only refuse to stream at one the path drops, which is undetectable from either endpoint’s registers. A device that answers no test packet keeps its size unchanged, so cameras that never implemented the mechanism are not walked down to 1500. StreamBuilder::probe(false) turns it off and makes preserve literal.

Cameras clamp a packet size they cannot honour, and the write succeeds when they do — nothing on the wire distinguishes “accepted” from “accepted and reduced”. The library reads GevSCPSPacketSize back after writing it and follows the effective value, logging a warning when the two differ.

If a stream produces nothing, the library says so after a few seconds and names the likely causes rather than leaving you with frames=0. Two messages are worth recognising:

  • no GVSP packet has arrived — a firewall, a lost control privilege, a camera waiting for a trigger, or a path MTU smaller than the packet size. Try --packet-size 9000 or 1500 before assuming the camera is broken.
  • packets are arriving but no frame has completed — the two ends disagree about the packet size. Retry with --packet-size 1500.

See Networking → MTU and jumbo frames for the host-side configuration.

4.2. Packet delay

Many cameras expose GevSCPD, an inter-packet gap in timestamp ticks. Zero means the camera sends a frame as fast as the link allows, which is what overruns switch buffers and NIC rings first. A modest delay trades a little latency for a lot of stability, and is usually the first thing to try when drops appear only at high frame rates.

4.3. What the statistics do and do not tell you

drops counts frames that arrived incomplete. That number is real, and it is the one to watch.

resends is not. GVSP defines packet resend, and this library contains the pieces — a resend planner, and the GVCP command to request one — but they are not wired into the receive path (backlog SR-04). Nothing in a real stream increments that counter, so resends=0 means “not implemented”, not “none were needed”. Do not read it as evidence that your network is healthy; read drops instead.

The same applies to backpressure_drops and the resend-range counters exposed on StreamStats. When resend lands, this section changes.


5. Troubleshooting

Drops spike immediately. Check MTU and packet size alignment first, then lower the frame rate or ROI to confirm it is a bandwidth problem rather than a configuration one. A dedicated NIC and switch removes a whole class of cause.

Discovery and feature access work, but no frames arrive. Almost always the stream port: check the firewall for inbound UDP on --port (10040 by default). On Linux with firewalld, this is a separate rule from the GVCP one — see Letting the reply back in.

Frames arrive with the wrong size or format. Read PixelFormat, Width and Height back from the camera rather than assuming; a Bayer format interpreted as Mono8 looks like a plausible grey image with a fine crosshatch.

Intermittent hiccups under load. Look at CPU usage and other traffic on the same NIC. On Windows, check the power profile and NIC driver version — receive buffers default low on many desktop NICs.

When in doubt, save a few frames, re-run with -vv, and compare against the vendor’s viewer on the same cabling. If it still makes no sense, send us the report bundle.


6. Recap

You should now be able to:

  • Start a stream from the CLI and from Rust, and save frames.
  • Read drops as the meaningful reliability signal, and know that resends is not one yet.
  • Know which knob to reach for first: packet size and MTU, then packet delay.

Next: the Networking Guide for host and switch configuration, or the viva-gige chapter for how the packets are actually handled.

Testing without hardware

This tutorial shows how to evaluate the full viva-genicam stack without physical cameras or external tools. The viva-fake-gige crate provides an in-process GigE Vision camera simulator that speaks real GVCP/GVSP protocols on localhost.

Quick start

# Run the self-contained demo
cargo run -p viva-genicam --example demo_fake_camera

The demo starts a camera, discovers it, connects, reads and writes features, and streams five frames — the whole stack, on loopback:

Starting fake GigE Vision camera on 127.0.0.1:3956 ...
Discovering cameras (2 s timeout) ...
  Found 1 device(s):
    IP: 127.0.0.1  Model: FakeGigE  Manufacturer: viva-genicam

Connecting to 127.0.0.1 ...
  Connected. GenApi XML: 22246 bytes, 64 features.

Reading camera features:
  Width = 640
  ...

Setting Width = 320, ExposureTime = 10000 ...
  Width readback = 320

Streaming 5 frames ...
  Frame 1: 320x480 Mono8 payload=153600B ts=7393542
  ...

Demo complete. All operations succeeded without hardware.

What the fake camera supports

FeatureStatus
GVCP discovery (broadcast on loopback)Supported
GenCP register read/write (READREG, WRITEREG, READMEM, WRITEMEM)Supported
Control Channel Privilege (CCP)Supported
GenApi XML with SFNC featuresWidth, Height, PixelFormat, ExposureTime, Gain
GVSP frame streamingSynthetic gradient images at configurable FPS
Device timestamps (1 GHz tick rate)Supported (ns since acquisition start)
Timestamp latch (GevTimestampValue)Supported
Chunk data (Timestamp, ExposureTime)Supported when ChunkModeActive=1

Running integration tests

All integration tests use the fake camera automatically:

# Full workspace test suite (includes fake camera tests)
cargo test --workspace

# Just the camera integration tests
cargo test -p viva-genicam --test fake_camera

# Zenoh service end-to-end tests
cargo test -p viva-service --test fake_camera_e2e

# The USB3 Vision equivalent, against viva-fake-u3v
cargo test -p viva-genicam --test fake_u3v_camera --features u3v

Using the fake camera in your own code

Add viva-fake-gige as a dev-dependency:

[dev-dependencies]
viva-fake-gige = { git = "https://github.com/VitalyVorobyev/viva-genicam" }

Start a fake camera in your test. The value build() returns is a guard: the camera answers GVCP and streams GVSP for as long as it is alive, and shuts down when dropped.

    // The guard owns the camera's tasks: it answers GVCP and streams GVSP for
    // as long as it is alive, and shuts down when dropped.
    let _camera_guard = FakeCamera::builder()
        .width(640)
        .height(480)
        .fps(10)
        .bind_ip([127, 0, 0, 1].into())
        .port(3956)
        .build()
        .await?;

Then discover it — note discover_all, not discover, because the fake lives on loopback:

    println!("Connecting to {} ...", dev_info.ip);
    let (mut camera, xml) = connect_gige_with_xml(dev_info).await?;
    println!(
        "  Connected. GenApi XML: {} bytes, {} features.\n",
        xml.len(),
        camera.nodemap().node_names().count()
    );

From there it is the ordinary API:

    // `get` and `set` are synchronous. They block on register I/O, and
    // `GigeRegisterIo` steps off the async worker itself, so no `spawn_blocking`
    // wrapper is needed even here inside `#[tokio::main]`.
    println!("Reading camera features:");
    for feature in [
        "Width",
        "Height",
        "PixelFormat",
        "ExposureTime",
        "Gain",
        "GevTimestampTickFrequency",
    ] {
        match camera.get(feature) {
            Ok(value) => println!("  {feature} = {value}"),
            Err(err) => println!("  {feature} = <error: {err}>"),
        }
    }
    println!();

    // ── 5. Write a feature ──────────────────────────────────────────────────
    println!("Setting Width = 320, ExposureTime = 10000 ...");
    camera.set("Width", "320")?;
    camera.set_exposure_time_us(10_000.0)?;
    println!("  Width readback = {}\n", camera.get("Width")?);

The full program those excerpts come from is examples/demo_fake_camera.rs, which goes on to build a FrameStream and grab five frames.

Running as a standalone server

The viva-fake-gige binary starts a long-running fake camera that stays alive until Ctrl+C. This is the way to test interactively with viva-camctl, or with viva-service and Viva Studio.

# Terminal 1: start the fake camera
cargo run -p viva-fake-gige

# Custom dimensions and frame rate
cargo run -p viva-fake-gige -- --width 512 --height 512 --fps 15

Output:

Fake camera running on 127.0.0.1:3956 (640x480 Mono8 @ 30 fps)
Press Ctrl+C to stop.

Using the CLI with the fake camera

With the fake camera running in Terminal 1, use viva-camctl in Terminal 2:

# Discover (use --iface to include loopback)
cargo run -p viva-camctl -- list --iface 127.0.0.1

# Read / write features
cargo run -p viva-camctl -- get --ip 127.0.0.1 --name Width
cargo run -p viva-camctl -- set --ip 127.0.0.1 --name Width --value 512
cargo run -p viva-camctl -- get --ip 127.0.0.1 --name DeviceModelName

E2E testing with Viva Studio

Viva Studio lives in studio/ in this repository, as a separate Cargo workspace. The full-stack test uses three terminals:

# Terminal 1: fake camera
cargo run -p viva-fake-gige

# Terminal 2: camera service (bridges the camera onto Zenoh).
# --zenoh-config is required on the service side so Studio can connect via TCP.
cargo run -p viva-service -- \
  --iface lo0 \
  --zenoh-config studio/config/zenoh-local.json5
# On Linux: --iface lo

# Terminal 3: the desktop app (loads its own Zenoh config in dev mode)
cd studio/apps/viva-studio-tauri
cargo tauri dev

Studio should discover the fake camera, show its feature tree, and stream gradient images in the viewer.

For USB3 Vision the service can host its own fake, so two terminals suffice:

cargo run -p viva-service-u3v -- --fake --zenoh-config studio/config/zenoh-local.json5
cd studio/apps/viva-studio-tauri && cargo tauri dev

Fake camera configuration

The FakeCameraBuilder supports:

let camera = FakeCamera::builder()
    .width(1920)                     // default: 640
    .height(1080)                    // default: 480
    .fps(60)                         // default: 30
    .bind_ip([127, 0, 0, 1].into())  // default: 127.0.0.1
    .port(3956)                      // default: 3956
    .pixel_format(viva_fake_gige::RGB8)  // default: MONO8
    .zip_xml(true)                   // default: false
    .enforce_heartbeat(true)         // default: false
    .heartbeat_timeout_ms(3_000)     // default: the device's own value
    .build()
    .await
    .unwrap();

The last three are there to reproduce behaviour real cameras have and fakes usually do not:

  • zip_xml serves the GenApi document ZIP-compressed, which many vendors do and which exercises a decompression path that would otherwise never be tested.
  • enforce_heartbeat makes the camera actually revoke control privilege when no GVCP command arrives inside heartbeat_timeout_ms. Without it, a client that forgets to send keepalives passes every test and fails on real hardware.

Image dimensions, exposure and gain can also be changed at runtime through GenApi writes — the fake responds to Width, Height, ExposureTime and Gain the way a real camera does.

Python bindings

The viva-genicam Python package wraps the Rust workspace behind a NumPy-friendly API. It ships as a pre-built wheel on PyPI — no C toolchain, no aravis, libusb is statically bundled.

pip install viva-genicam

The install also provides the viva-camctl CLI — see Install & hello-camera.

import viva_genicam as vg

cams = vg.discover(timeout_ms=500)
cam = vg.connect_gige(cams[0])
print(cam.get("DeviceModelName"))

with cam.stream() as frames:
    for frame in frames:
        arr = frame.to_numpy()           # NumPy (H, W) or (H, W, 3) uint8
        break

Tutorials

  1. Install & hello-camera — install the wheel, run the self-contained fake-camera demo.
  2. Discovery — enumerate GigE and U3V cameras, restrict to one NIC, auto-detect interfaces.
  3. Control & introspection — read and write features, walk the NodeMap, discover which features apply.
  4. Streaming — context-manager streams, NumPy frames, pixel formats, timestamps.

Reference

  • API reference — every public class, function, and exception in one place.
  • Example scripts — runnable Python files mirroring the most common Rust examples.

Supported

  • Python 3.9+, abi3 wheels (one wheel covers every minor version).
  • GigE Vision: discovery, control, streaming.
  • USB3 Vision: discovery, control, streaming.
  • Platforms with pre-built wheels: Linux x86_64 (manylinux_2_28), macOS arm64, Windows x86_64.

Not exposed to Python yet

The Rust API is wider than the bindings. These exist in viva-genicam and have no Python equivalent today:

  • Chunk data. frame.chunks is not surfaced; ChunkConfig and configure_chunks are Rust-only.
  • Events. There is no binding for the message channel or EventStream.
  • Time sync. time_calibrate is not exposed, which also means frame.ts_host has no device mapping to work from — see Streaming.
  • Action commands and FORCEIP / persistent IP configuration.
  • Skipped nodes. NodeMap::skipped() — the list of features we could not build from this camera’s XML — is reachable from viva-camctl but not from Python (backlog DX-05). A missing feature is therefore indistinguishable from one the camera does not have.

If you need one of these, viva-camctl covers most of them from the command line, and the Rust crates cover all of them.

Need another platform? The sdist on PyPI builds from source — you’ll need a Rust toolchain (rustup) and a C compiler. libusb is always statically vendored; no system package needed.

Install & hello-camera

Install from PyPI

pip install viva-genicam

Wheels ship for:

OSArchPython
Linux (manylinux_2_28)x86_643.9+ (abi3)
macOSarm643.9+ (abi3)
Windowsx86_643.9+ (abi3)

libusb is statically linked into the extension module — no need to apt install libusb-1.0-0-dev or brew install libusb on the install side.

If you are on a platform without a pre-built wheel, pip falls back to the sdist; you will need a Rust toolchain (rustup) and a C compiler installed.

The CLI comes with it

The same install provides viva-camctl, the diagnostic CLI. It is linked into the extension module rather than shipped as a separate binary, so it is present in every wheel and in an sdist build too:

viva-camctl list
viva-camctl report --ip 192.168.0.10 --out viva-report.txt   # bug-report bundle
viva-camctl xml    --ip 192.168.0.10 --out camera.xml        # just the GenApi XML

Both report and xml stop before building the nodemap, so they work on a camera the library cannot open — which is the only camera anyone reports. Attach the output to a GitHub issue.

Verify the install

import viva_genicam as vg
print(vg.__version__)
print(vg.discover(timeout_ms=300))

If no cameras are physically connected, you should see an empty list — not an exception.

Hello camera — no hardware needed

The wheel ships an in-process fake GigE Vision camera. Just run:

import viva_genicam as vg
from viva_genicam.testing import FakeGigeCamera

with FakeGigeCamera(width=640, height=480, fps=10) as fake:
    cam = vg.connect_gige(fake.device_info())
    print(cam.get("DeviceModelName"))
    with cam.stream() as frames:
        frame = frames.next_frame(timeout_ms=5000)
        print(frame.width, frame.height, frame.pixel_format)

No clone, no cargo build, no subprocess — the fake camera lives inside the same process as your script.

For a fuller end-to-end walkthrough, the repo ships a runnable example:

python crates/viva-pygenicam/examples/demo_fake_camera.py

Expected output:

1. Starting in-process fake GigE camera ...
   bound to 127.0.0.1:3956
2. Discovering ...
   found FakeGigE @ 127.0.0.1
3. Connecting ...
   connected; XML is 16115 bytes, 53 features
4. Reading features:
   Width          = 640
   Height         = 480
   ...
6. Streaming 5 frames ...
   frame 1: 640x480 Mono8  numpy shape=(480, 640) dtype=uint8
   ...
Demo complete — everything ran without any real hardware.

This covers discovery, connection, feature read/write, and streaming — the full surface you will use with a real camera.

Next

Discovery — enumerate cameras with interface control.

Discovery

GigE and USB3 Vision have separate discovery pipelines. Both return frozen dataclasses you can pass straight to connect_gige / connect_u3v.

GigE Vision

import viva_genicam as vg

cams = vg.discover(timeout_ms=500)
for c in cams:
    print(c.ip, c.mac, c.model, c.manufacturer)

vg.discover() sends a GVCP DISCOVERY_CMD broadcast on the default outbound interface and collects ack packets for timeout_ms milliseconds. Returns a list of GigeDeviceInfo:

@dataclass(frozen=True)
class GigeDeviceInfo:
    ip: str                        # "192.168.1.42"
    mac: str                       # "DE:AD:BE:EF:CA:FE"
    manufacturer: Optional[str]
    model: Optional[str]
    transport: Literal["gige"]

Restrict to one NIC

cams = vg.discover(timeout_ms=500, iface="en0")
cams = vg.discover(timeout_ms=500, iface="192.168.0.5")   # same thing

Use this when the host has multiple NICs and you only want to broadcast out one of them.

iface= accepts the host NIC’s OS name or one of its IPv4 addresses. On Windows the name is a GUID like {6394C55F-F630-4BC7-92D2-7AC320C73D1C}, so the address is usually the easier value to supply.

Scan every NIC

cams = vg.discover(timeout_ms=500, all=True)

Enumerates every local interface, broadcasts on each, and merges the results. This is what you want on a developer machine where you may not know ahead of time which NIC the camera is on.

Slower cameras

Some cameras are slow to reply or sit on busy networks. Bump the timeout:

cams = vg.discover(timeout_ms=3000, all=True)

USB3 Vision

cams = vg.discover_u3v()
for c in cams:
    print(f"vid:pid=0x{c.vendor_id:04x}:0x{c.product_id:04x}")
    print(f"  bus={c.bus} addr={c.address}")
    print(f"  model={c.model}  serial={c.serial}")

vg.discover_u3v() enumerates USB devices whose interface descriptors match the USB3 Vision class/subclass/protocol triple. Returns a list of U3vDeviceInfo:

@dataclass(frozen=True)
class U3vDeviceInfo:
    bus: int
    address: int
    vendor_id: int
    product_id: int
    serial: Optional[str]
    manufacturer: Optional[str]
    model: Optional[str]
    transport: Literal["u3v"]

USB discovery is synchronous (there is no timeout_ms knob) and does not require any broadcast.

Connecting

Either DeviceInfo type can be passed directly:

cam = vg.connect_gige(cams[0])                 # GigE
cam = vg.connect_u3v(u3v_cams[0])              # U3V
cam = vg.Camera.open(cams[0])                  # dispatches on info type

connect_gige accepts an optional iface= override if you know which NIC should stream from the camera:

cam = vg.connect_gige(info, iface="en0")
cam = vg.connect_gige(info, iface="192.168.0.5")   # same thing

When omitted, the stream interface is auto-resolved by matching the camera IP against every local NIC’s subnet. For loopback (e.g. the fake camera) this resolves to lo/lo0 automatically.

Next

Control & introspection — read and write features, walk the NodeMap.

Control & introspection

Reading and writing features

Camera.get(name) returns the value as a string, formatted per the node’s type. Camera.set(name, value) parses the string according to the node type and writes it.

cam.get("ExposureTime")         # "5000"
cam.get("PixelFormat")          # "Mono8"
cam.get("Width")                # "640"

cam.set("Width", "320")
cam.set("PixelFormat", "Mono8")
cam.set("ExposureTime", "7500.0")

Typed helpers

Two SFNC-standard features have dedicated float setters so you don’t pass numbers as strings:

cam.set_exposure_time_us(10_000.0)
cam.set_gain_db(6.0)

These write the canonical SFNC names ExposureTime and Gain as floats, and raise GenApiError if the camera calls them something else. They are a typed convenience over set, not a compatibility layer — there is no vendor-alias fallback here. If your camera uses a different name, pass it to set directly:

cam.set("ExposureTimeAbs", "10000")

Error model

Every control error raises a subclass of vg.GenicamError:

try:
    cam.set("Width", "not-a-number")
except vg.ParseError as e:
    print("bad input:", e)
except vg.GenApiError as e:
    print("nodemap rejected the write:", e)
except vg.TransportError as e:
    print("register I/O failed:", e)
ExceptionWhen
GenApiErrorNodemap evaluation: unknown feature, value out of range, predicate failed
TransportErrorGVCP/USB register read or write failed
ParseErrorUser-supplied value couldn’t be parsed per the node’s type
MissingChunkFeatureErrorChunk selector not present in the camera’s XML
UnsupportedPixelFormatErrorNo RGB conversion path for the reported pixel format

All five inherit from GenicamError, so except vg.GenicamError: catches every error the bindings raise deliberately. Two failures escape it:

  • Using a Camera, Frame or FrameStream from a thread other than the one that created it raises pyo3_runtime.PanicException, which subclasses BaseException — so even except Exception: misses it. These objects are unsendable; keep each one on its own thread.
  • A panic anywhere in the Rust layer surfaces the same way.

If you are wrapping this in a service that must not die, catch BaseException at the top of the worker as well.

Introspection

List features

cam.nodes()            # ['AcquisitionStart', 'ExposureTime', ... 53 entries]

Node metadata

info = cam.node_info("ExposureTime")
print(info.kind)         # "Float"
print(info.access)       # "RW"
print(info.visibility)   # "Beginner"
print(info.description)  # "Exposure time of the sensor in microseconds."
print(info.writable)     # True
print(info.readable)     # True

NodeInfo fields:

  • name — feature name
  • kind"Integer", "Float", "Enumeration", "Boolean", "Command", "Category", "SwissKnife", "Converter", "IntConverter", "StringReg"
  • access"RO", "RW", "WO", or None (for categories)
  • visibility"Beginner", "Expert", "Guru", "Invisible"
  • display_name, description, tooltip

Plus two convenience properties: readable (access in {"RO","RW"}) and writable (access in {"RW","WO"}).

Enum entries

cam.enum_entries("PixelFormat")
# ['Mono8', 'Mono16', 'BayerRG8', 'RGB8Packed']

Categories

cats = cam.categories()
for cat, children in cats.items():
    print(cat, "->", children)

The categories map mirrors the GenICam XML category tree; each value is the list of child feature names. Use this to render a tree UI or to filter features by area (acquisition, image format, device control, etc.).

All node metadata at once

for info in cam.all_node_info():
    print(info.name, info.kind, info.access)

Useful for exporting a CSV, auto-generating GUI forms, or diffing two cameras’ feature surfaces.

Acquisition control

Without streaming (for example, trigger-mode tests):

cam.acquisition_start()
# ... do something that causes frames to be produced on another channel ...
cam.acquisition_stop()

When you use with cam.stream() as frames: the stream context manager calls these for you on entry/exit. Don’t call them manually if you are using stream().

Raw XML

print(cam.xml[:500])     # first 500 chars of the GenICam XML

Handy for feeding into a GenICam tool, debugging a mystery feature, or archiving the exact schema a camera presented at connect time.

Next

Streaming — sync iterator, NumPy frames, timestamps.

Streaming

Camera.stream() returns a context manager that starts acquisition on entry, stops it on exit, and yields Frame objects while it’s open:

with cam.stream() as frames:
    for frame in frames:
        arr = frame.to_numpy()
        ...

No asyncio required — the underlying tokio runtime is managed inside the extension, and iteration blocks the calling thread while other Python threads stay runnable (the GIL is released during the blocking read).

The Frame object

frame.width              # int
frame.height             # int
frame.pixel_format       # "Mono8", "Mono16", "BayerRG8", "RGB8Packed", ...
frame.pixel_format_code  # raw PFNC integer
frame.ts_dev             # device tick count, Optional[int]
frame.ts_host            # POSIX seconds, Optional[float] (only if time-synced)
frame.payload()          # raw bytes, one copy

to_numpy() — natural shape

arr = frame.to_numpy()
Pixel formatArray shapedtype
Mono8(H, W)uint8
Mono16(H, W)uint16
RGB8Packed(H, W, 3)uint8
BGR8Packed, BayerRG8, BayerGB8, BayerBG8, BayerGR8(H, W, 3)uint8 (auto-demosaiced / reordered)
anything else(N,) rawuint8

Demosaicing is a simple nearest-neighbour kernel inside the Rust to_rgb8() path — fine for preview, not a substitute for an ISP.

to_rgb8() — always RGB

rgb = frame.to_rgb8()     # always (H, W, 3) uint8

Useful when you want one code path regardless of the camera’s pixel format.

Raw bytes

frame.payload()           # bytes, copy of the whole GVSP payload

Use this when you need to feed bytes to another decoder or serialize to disk as-is.

Streaming options

The stream() call accepts GigE-specific knobs:

cam.stream(
    iface="en0",               # NIC override — or "192.168.0.5"
    packet_size=1500,          # omit to follow the interface's probed MTU
    multicast="239.255.42.99", # subscribe to a multicast group instead of unicast
    destination_port=34567,    # fix the streaming UDP port
)

None of these are required. iface= is auto-resolved by subnet match if you omit it; the rest fall back to the camera’s defaults.

iface= takes the host NIC’s IPv4 address or its OS name, the same two spellings viva-camctl --iface and viva-service --iface accept — so the address you read off discover() is a legal value here.

For U3V cameras all options are silently ignored.

Timeouts and ending a stream

Iteration blocks until a frame arrives. To time out a single read:

with cam.stream() as frames:
    frame = frames.next_frame(timeout_ms=1000)
    if frame is None:
        print("stream ended cleanly")
    else:
        ...

next_frame() returns None when the stream closes cleanly, or raises TransportError on timeout / network failure. The for frame in frames path uses a 5-second default timeout that raises on expiry.

Exit the with block to stop acquisition and release the socket / USB endpoint. You can also call frames.close() explicitly if you stored the iterator outside a with statement.

Complete example: save 5 frames as PNGs

import viva_genicam as vg
from PIL import Image

cam = vg.connect_gige(vg.discover(timeout_ms=500)[0])

with cam.stream() as frames:
    for i, frame in enumerate(frames, 1):
        Image.fromarray(frame.to_numpy()).save(f"frame_{i:03d}.png")
        if i >= 5:
            break

Identical in spirit to the Rust grab_gige example, 12 lines of Python.

Next

API reference — every public symbol, in one page.

API reference

Every public symbol exported from viva_genicam.

Discovery

vg.discover(timeout_ms=500, iface=None, all=False) -> list[GigeDeviceInfo]
vg.discover_u3v() -> list[U3vDeviceInfo]
@dataclass(frozen=True)
class GigeDeviceInfo:
    ip: str
    mac: str
    manufacturer: Optional[str]
    model: Optional[str]
    transport: Literal["gige"]

@dataclass(frozen=True)
class U3vDeviceInfo:
    bus: int
    address: int
    vendor_id: int
    product_id: int
    serial: Optional[str]
    manufacturer: Optional[str]
    model: Optional[str]
    transport: Literal["u3v"]

Both dataclasses expose .to_dict() for JSON-friendly export.

DeviceInfo is the Union[GigeDeviceInfo, U3vDeviceInfo] alias.

Connection

vg.connect_gige(device_info: GigeDeviceInfo, iface: Optional[str] = None) -> Camera
vg.connect_u3v(device_info: U3vDeviceInfo) -> Camera
vg.Camera.open(device_info, **kwargs) -> Camera   # dispatches on type

Camera

class Camera:
    transport: str                           # "gige" or "u3v"
    xml: str                                 # raw GenICam XML

    def get(self, name: str) -> str: ...
    def set(self, name: str, value: str) -> None: ...
    def set_exposure_time_us(self, value: float) -> None: ...
    def set_gain_db(self, value: float) -> None: ...
    def enum_entries(self, name: str) -> list[str]: ...

    def nodes(self) -> list[str]: ...
    def node_info(self, name: str) -> Optional[NodeInfo]: ...
    def all_node_info(self) -> list[NodeInfo]: ...
    def categories(self) -> dict[str, list[str]]: ...

    def acquisition_start(self) -> None: ...
    def acquisition_stop(self) -> None: ...

    def stream(
        self,
        iface: Optional[str] = None,
        packet_size: Optional[int] = None,
        multicast: Optional[str] = None,
        destination_port: Optional[int] = None,
    ) -> FrameStream: ...

NodeInfo

class NodeKind(str, Enum):
    INTEGER       = "Integer"
    FLOAT         = "Float"
    ENUMERATION   = "Enumeration"
    BOOLEAN       = "Boolean"
    COMMAND       = "Command"
    CATEGORY      = "Category"
    SWISS_KNIFE   = "SwissKnife"
    CONVERTER     = "Converter"
    INT_CONVERTER = "IntConverter"
    STRING_REG    = "StringReg"

@dataclass(frozen=True)
class NodeInfo:
    name: str
    kind: str
    access: Optional[str]              # "RO" | "RW" | "WO" | None
    visibility: str                    # "Beginner" | "Expert" | "Guru" | "Invisible"
    display_name: Optional[str]
    description: Optional[str]
    tooltip: Optional[str]

    @property
    def readable(self) -> bool: ...
    @property
    def writable(self) -> bool: ...
    def to_dict(self) -> dict: ...

FrameStream

class FrameStream:
    def __enter__(self) -> "FrameStream": ...      # calls acquisition_start()
    def __exit__(self, *exc) -> None: ...           # calls acquisition_stop() + close()
    def __iter__(self) -> Iterator[Frame]: ...
    def __next__(self) -> Frame: ...                # 5-second default timeout
    def next_frame(self, timeout_ms: Optional[int] = None) -> Optional[Frame]: ...
    def close(self) -> None: ...

Frame

class Frame:
    width: int
    height: int
    pixel_format: str
    pixel_format_code: int
    ts_dev: Optional[int]
    ts_host: Optional[float]

    def payload(self) -> bytes: ...
    def to_numpy(self) -> numpy.ndarray: ...        # natural shape per pixel format
    def to_rgb8(self) -> numpy.ndarray: ...         # always (H, W, 3) uint8

Exceptions

GenicamError                      # base class
├── GenApiError
├── TransportError
├── ParseError
├── MissingChunkFeatureError
└── UnsupportedPixelFormatError

Everything the bindings raise deliberately inherits from GenicamError, so one except vg.GenicamError: covers it.

It does not cover a pyo3_runtime.PanicException, which is what you get if a Camera, Frame or FrameStream is used from a thread other than the one that created it (they are pyo3 unsendable types), or if the Rust layer panics. PanicException subclasses BaseException, so except Exception: misses it too — see Control & introspection.

Networking

This chapter is a practical GigE Vision networking cookbook.

It focuses on:

  • Typical topologies (direct cable vs switch, single vs multi-camera).
  • NIC and IP configuration on Windows, Linux, and macOS.
  • MTU / jumbo frames and packet delay basics.
  • Common pitfalls and troubleshooting.

It is not a replacement for vendor or A3 documentation, but gives you enough background to make viva-camctl and the viva-genicam examples work reliably. oai_citation:0‡Wikipedia

If you have not yet done so, first go through:

They show the CLI and Rust-side pieces that depend on a working network setup.


1. Typical topologies

1.1. Single camera, direct connection

The simplest and most robust setup:

[Camera]  <── Ethernet cable ──>  [Host NIC]

Characteristics:

  • One camera, one host, one NIC.
  • No other traffic on that link.
  • Easy to reason about MTU and packet delay.

Recommended when:

  • You’re bringing up a new camera.
  • You’re debugging issues and want to remove variables.

1.2. One or more cameras through a switch

Common in real systems:

[Cam A] ──\
           \
[Cam B] ────[Switch]──[Host NIC]
           /
[Cam C] ─/

Characteristics:

  • Multiple cameras share the link to the host.
  • Switch must handle the aggregate throughput.
  • Switch configuration (buffer sizes, jumbo frames, spanning tree) matters. 

Recommended when:

  • You need more than one camera.
  • You need long cable runs or multi-drop layouts.

1.3. Host with multiple NICs

For high throughput or separation from office traffic:

[Cam network]  <── NIC #1 ──>  [Host]  <── NIC #2 ──>  [Office / internet]

Characteristics:

  • Camera traffic isolated from general network.
  • Easier to tune MTU, QoS, and firewall rules.
  • In discovery and streaming, you may need to specify –iface (see §7).

Recommended for:

  • High data rates.
  • Multi-camera setups.
  • Systems that must not be disturbed by office network traffic.

2. IP addressing basics

GigE Vision uses standard IPv4 + UDP. Each device needs a valid IPv4 address; the host and camera(s) must share a subnet. 

2.1. Choose a camera subnet

Pick a private network, for example:

  • 192.168.0.0/24 (addresses 192.168.0.1–192.168.0.254)
  • 10.0.0.0/24

Decide on:

  • One address for your host NIC (e.g. 192.168.0.5).
  • One address per camera (e.g. 192.168.0.10, 192.168.0.11, …).

Make sure this subnet does not conflict with your office / internet network.

2.2. Windows

  1. Open Network & Internet Settings → Change adapter options.
  2. Right-click the NIC used for cameras → Properties.
  3. Select Internet Protocol Version 4 (TCP/IPv4) → Properties.
  4. Choose Use the following IP address:
    • IP address: e.g. 192.168.0.5
    • Subnet mask: 255.255.255.0
    • Gateway: leave empty (for isolated camera networks).
  5. Turn off any “energy saving” features for this NIC in the driver settings if possible (they can introduce latency/jitter).

On first run, Windows firewall may pop up asking whether to allow the binary on Private / Public networks. Allow it on the relevant profile so UDP broadcasts work.

2.3. Linux

Use either NetworkManager or manual configuration.

Manual example:

# Assign IP and bring interface up (replace eth1 with your device)
sudo ip addr add 192.168.0.5/24 dev eth1
sudo ip link set eth1 up

To make this permanent, use your distro’s network configuration tools (e.g. Netplan on Ubuntu, ifcfg files on RHEL, etc.).

2.4. macOS

Use System Settings → Network:

  1. Select the camera NIC (e.g. USB Ethernet).
  2. Set “Configure IPv4” to “Manually”.
  3. Enter:
    • IP address: 192.168.0.5
    • Subnet mask: 255.255.255.0
  4. Leave router/gateway empty for a dedicated camera network.

A GigE Vision camera with no static IP and no DHCP server on the segment falls back to an IPv4 link-local address in 169.254.0.0/16 — what Windows calls APIPA. This is the normal state of a camera plugged straight into a host with nothing else configured, so it is worth knowing even if you plan to assign static addresses later.

The library discovers such cameras on all platforms, but the host must hold a link-local address of its own first, and on Linux the firewall usually has to be told to let the reply back in.

Most of this section comes from a bring-up performed by @InsuJeong496 on a JAI FS-3200T-10GE-NNC and written up in issue #57. Their results: discovery succeeded, the MAC parsed correctly, the GenApi XML downloaded, 1 065 features loaded, and end-to-end streaming worked once the two firewall rules below were in place — with no vendor driver installed.

3.1. What is fixed and what is yours

The single most useful thing in that report was the distinction between the values the protocol fixes and the values that belong to one particular machine. Copying an address out of someone else’s guide is the usual reason these recipes fail.

Fixed — do not change these:

ItemValue
IPv4 link-local network169.254.0.0/16
Directed broadcast for that network169.254.255.255
GVCP port on the cameraUDP 3956

Yours — substitute your own:

ItemWhere to get it
Host interface nameip -brief address (Linux)
Host link-local addressLet the OS assign one, or pick an unused 169.254.x.y
Camera addressWhatever viva-camctl list reports; it can change between sessions
firewalld zonefirewall-cmd --get-active-zones
GVSP destination port10040 unless you pass --port to viva-camctl stream

Normally NetworkManager assigns one automatically when a link comes up with no DHCP answer. If it has not, add one by hand:

# Replace enp9s0f3u3 with your camera NIC. Keep the /16 and the broadcast.
sudo ip address add 169.254.105.107/16 \
  broadcast 169.254.255.255 \
  dev enp9s0f3u3

Check what an interface currently has:

ip -brief address

Once the host has a link-local address, discovery sends its directed broadcast to 169.254.255.255:3956.

On Windows this step is normally automatic — an adapter with no DHCP lease self-assigns a 169.254.x.y address. On macOS the same is true, though see the MTU note in §4: jumbo frames cannot be selected there.

3.3. Letting the reply back in (firewalld)

The most common symptom is that discovery finds nothing while the camera is plainly on the link. The camera answers from UDP port 3956 to whatever ephemeral port the client bound, so a default-deny inbound policy drops the ACK and discovery simply times out.

Find the zone that owns the camera interface, then allow that source port:

firewall-cmd --get-active-zones

sudo firewall-cmd --zone=public \
  --add-rich-rule='rule family="ipv4" source address="169.254.0.0/16" source-port port="3956" protocol="udp" accept'

Replace public with your zone. Keep 169.254.0.0/16 and 3956.

Streaming needs a second rule, because GVSP arrives on the port the host asked the camera to send to — 10040 by default:

sudo firewall-cmd --zone=public --add-port=10040/udp

If you override the port, allow the one you actually use:

viva-camctl stream --ip <camera-ip> --iface <host-link-local-ip> --port <PORT>
sudo firewall-cmd --zone=<your-zone> --add-port=<PORT>/udp

These are runtime rules. They vanish on the next firewall reload or reboot unless you repeat them with --permanent.

3.4. Checking it worked

viva-camctl --iface <host-link-local-ip> list

At -v the discovery log names the interface it sent from and the address it heard back from:

INFO sending GVCP discovery interface_name=enp9s0f3u3 local=169.254.105.107 dest=169.254.255.255:3956
INFO received GVCP response interface_name=enp9s0f3u3 src=169.254.253.222:3956

If the first line is missing, the host has no link-local address on that NIC (§3.2). If the first line appears but the second never does, suspect the firewall (§3.3).

4. MTU and jumbo frames

MTU (Maximum Transmission Unit) determines the largest Ethernet frame size. Standard MTU is 1500 bytes; jumbo frames extend this (e.g. 9000 bytes). For large images, jumbo frames can significantly reduce protocol overhead and CPU load. 

4.1. When to care

You probably need to look at MTU when:

  • Frame sizes are large (multi-megapixel).
  • Frame rates are high (tens or hundreds of FPS).
  • You see lots of packet drops or resends at otherwise reasonable loads.

For simple bring-up and low/moderate data rates, standard MTU=1500 usually works.

4.2. Enabling jumbo frames

All components in the path must agree:

  • Camera
  • Switch (if present)
  • Host NIC

The host NIC MTU is not the path MTU. A jumbo-capable NIC (e.g. 16128-byte jumbo / IPv4 MTU 16114) behind a switch that only forwards ~9216-byte frames will still let both ends configure a 16114-byte GevSCPSPacketSize; the switch drops the oversized GVSP datagrams and the stream shows frames=0. Direct camera↔NIC links on that same host have streamed at 16114. When in doubt, cap with viva-camctl stream --packet-size 9000 (or lower) rather than trusting the NIC alone. See Streaming → Packet size and MTU and ADR-0021.

Typical steps:

  • Camera: set GevSCPSPacketSize or similar feature to a value below the path MTU (e.g. 8192 for MTU 9000). You can use viva-camctl set or --packet-size on stream.
  • Switch: enable jumbo frames in the management UI (name and steps vary by vendor). Confirm the switch’s actual max frame size — “jumbo” is not one number.
  • Host NIC:
    • Windows: NIC properties → Advanced → Jumbo Packet or similar.
    • Linux: sudo ip link set dev eth1 mtu 9000
    • macOS: some drivers expose MTU setting in the network settings; others do not support jumbo frames.

After changing MTU, confirm with:

# Linux example
ip link show eth1

and check that TX/RX MTU matches your expectation.

5. Packet delay and flow control

Some cameras allow configuring inter-packet delay or packet interval:

  • Without delay:
    • Camera sends packets as fast as possible.
    • High instantaneous bursts can overwhelm NICs / switches.
  • With modest delay:
    • Traffic is smoother at the cost of a small increase in latency.

If you see high packet loss or many resends at high frame rates:

  1. Try slightly increasing the inter-packet delay.
  2. Observe:
    • Does the drop/resend rate decrease?
    • Is overall throughput still sufficient?

Some vendors also expose “frame rate limits” or “burst size” options. These can also be used to ease pressure on the network at the cost of lower peak FPS. 

6. Multi-camera considerations

When running multiple cameras:

  • Total throughput is roughly the sum of each camera’s stream.
  • The bottleneck can be:
    • The switch’s uplink to the host.
    • The host NIC’s capacity.
    • Host CPU / memory bandwidth.

Practical tips:

  • Prefer a dedicated NIC for cameras.
  • For 2–4 high-speed cameras, consider:
    • Multi-port NICs.
    • Separating cameras onto different NICs if possible.
  • Stagger packet timing:
    • Slightly different inter-packet delays for each camera.
    • Slightly different frame rates, where acceptable.

Monitor:

  • Per-camera stats (drops, resends, throughput).
  • Host CPU usage.
  • Switch port statistics if your hardware exposes them.

7. Using –iface and discovery quirks

On systems with more than one active NIC, automatic interface selection might pick the wrong one. --iface forces the choice, and means the same thing everywhere: in viva-camctl, in viva-service, in the Python iface= argument and in the Rust examples. It names the host NIC, by either

  • one of its IPv4 addresses — --iface 192.168.0.5, or
  • its OS name — --iface eth0, or a GUID like {6394C55F-F630-4BC7-92D2-7AC320C73D1C} on Windows.

Use whichever you have; the address is usually easier to find, and on Windows much easier. A value that resolves to nothing prints every interface the library can see, which is the fastest way to learn the GUID.

If discovery only works when you specify –iface, but not without it:

  • You likely have:
    • Multiple NICs on overlapping subnets, or
    • A default route that prefers a different interface.
  • This is not unusual; be explicit for production setups.

8. Troubleshooting checklist

Use this checklist when things don’t work as expected.

8.1. Discovery fails

See also the troubleshooting section in Discovery.

  • Check link LEDs on camera, switch, and NIC.
  • Confirm IP addressing:
    • Host and camera on same subnet.
    • No conflicting IPs.
  • Check firewall:
    • Allow UDP broadcast / unicast on the camera NIC.
  • Temporarily:
    • Disable other NICs to simplify routing.
    • Try a direct cable instead of a switch.

If the camera has a 169.254.x.y address, go to §3 Link-local (APIPA) cameras instead — the causes there are specific and the fixes are two commands.

If none of this helps, the camera itself is the evidence we need: see Reporting a camera we can’t open.

8.2. Streaming is unstable (drops / resends)

  • Check MTU vs packet size; avoid exceeding path MTU.
  • For high data rates:
    • Enable jumbo frames end-to-end (camera, switch, NIC).
  • Reduce stress:
    • Lower frame rate or ROI.
    • Increase inter-packet delay slightly.
  • Ensure dedicated NIC and switch where possible.
  • Watch host CPU; if it’s near 100%, consider:
    • Better NIC / driver.
    • Moving processing off to another thread / core.

8.3. Vendor tool works, viva-genicam does not

Compare:

  • Which NIC / IP the vendor tool uses.
    • The camera’s configured stream destination (IP/port).
    • The vendor tool might:
  • Use a different MTU / packet size.
    • Adjust inter-packet delay automatically.
    • Try to replicate those parameters with viva-camctl and the NodeMap.

  1. Recap

After this chapter you should: • Understand basic GigE Vision network topologies and when to use each. • Be able to configure a host NIC and camera addresses on Windows, Linux, and macOS. • Know when and how to enable jumbo frames and adjust packet delay. • Have a structured approach to debugging discovery and streaming issues.

For protocol-level details and tuning options exposed by this project: • See viva-gige for transport internals. • See the Streaming tutorial for concrete CLI and Rust examples.


Error Handling & Logging

Error Types

Each crate defines its own error type:

  • GenicamError – high-level facade errors
  • GigeError – GVCP/GVSP transport errors
  • GenApiError – node evaluation and register I/O errors
  • GenCpError – GenCP protocol encoding errors
  • XmlError – XML parsing errors

All error types implement std::error::Error and Display.

Logging

The workspace uses the tracing crate for structured logging. Enable it with:

tracing_subscriber::fmt::init();

Or set RUST_LOG=debug to see detailed protocol traces.

Testing

Unit Tests

cargo test --workspace

Unit tests are embedded in source modules (mod tests { }).

Integration Tests

The workspace includes viva-fake-gige, an in-process GigE Vision camera simulator. All integration tests run automatically with cargo test – no external tools or hardware required.

# Run all tests (unit + integration)
cargo test --workspace

# Run integration tests specifically
cargo test -p viva-genicam --test fake_camera

# Run viva-service end-to-end tests (Zenoh bridge)
cargo test -p viva-service --test fake_camera_e2e

The fake camera supports:

  • GVCP discovery on UDP (loopback)
  • GenCP register read/write with an embedded GenApi XML
  • GVSP streaming with synthetic image frames and real timestamps
  • Chunk data (timestamp, exposure time) when ChunkModeActive is enabled
  • Timestamp features (GevTimestampTickFrequency, GevTimestampValue, TimestampLatch)

Demo

Run the self-contained demo to see the full workflow without hardware:

cargo run -p viva-genicam --example demo_fake_camera

This starts a fake camera, discovers it, reads/writes features, and streams frames – all on localhost with zero setup.

Manual / Interactive Testing

For interactive testing or E2E testing with genicam-studio, start the fake camera as a standalone server:

# Stays alive until Ctrl+C
cargo run -p viva-fake-gige
cargo run -p viva-fake-gige -- --width 512 --height 512 --fps 15

Then use viva-camctl or viva-service to interact with it. See the Testing without hardware tutorial for details.

Contributing

Contributions are welcome! Please open an issue or pull request on GitHub.

Development Setup

# Build the workspace
cargo build --workspace

# Run tests (includes fake camera integration tests)
cargo test --workspace

# Lint
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --check

Code Style

  • Follow rustfmt defaults
  • Keep clippy warnings clean
  • Add doc comments to all public items

FAQ

This page collects short answers to questions that come up often when using viva-genicam or bringing up a new camera.

If you are stuck, also check:

and the issues in the GitHub repository.


“Discovery finds no cameras. What do I check first?”

Run:

cargo run -p viva-camctl -- list

If it shows nothing:

  1. Physical link
    • Are the link LEDs lit on camera, NIC, and switch?
    • Try a different cable or port.
  2. IP addresses
    • Host NIC and camera must be on the same subnet (e.g. 192.168.0.x/24).
    • Avoid having two NICs on the same subnet; routing will get confused.
  3. Firewall
    • Allow UDP broadcast/unicast on the NIC used for cameras.
    • On Windows, make sure the binary is allowed on the relevant network profile (Private / Domain).
  4. Multiple NICs
    • Use –iface to force the interface. It takes the host NIC’s IPv4 address or its OS name, whichever you have:
cargo run -p viva-camctl -- list --iface 192.168.0.5
cargo run -p viva-camctl -- list --iface eth0

If the camera’s address starts with 169.254., it is using link-local (APIPA) addressing — see Networking §3, which covers the host address and the two firewall rules that case needs.

See also: Discovery tutorial and Networking. If none of it helps, send us the camera’s own account of itself.

“The vendor viewer works but viva-genicam doesn’t. Why?”

Common causes:

  • Different NIC / interface:
    • The vendor tool may be using a different NIC or IP selection strategy.
    • Compare which local IP it uses and pass that as –iface to viva-camctl.
  • Different stream destination:
    • The camera might be configured to stream to a specific IP/port.
    • Ensure viva-genicam uses the same host IP and port, or reset the camera configuration to defaults.
  • Different MTU / packet size / packet delay:
    • Vendor tools sometimes auto-tune these.
    • Try matching their settings using GenApi features (packet size, frame rate, inter-packet delay).

When in doubt:

  • Capture logs with RUST_LOG=debug and compare behaviour at the same frame rate and resolution.

See: Streamingand Networking.

“Does this work on Windows?”

Yes. Windows is a first-class target alongside Linux and macOS.

Notes:

  • Make sure the firewall allows discovery and streaming:
    • When Windows asks whether to allow the executable on Private/Public networks, allow it on the profile you use for the camera network.
  • Configure the NIC for the camera network with a static IPv4 address, separate from your office/internet NIC.
  • For high-throughput setups:
    • Consider enabling jumbo frames on the camera NIC.
    • Disable power-saving features that can introduce latency.

See: Networking for NIC configuration details.

“How do I set exposure, gain, pixel format, etc.?”

Use the GenApi features via viva-camctl or the viva-genicam crate.

Examples with viva-camctl:

# Read ExposureTime
cargo run -p viva-camctl -- \
  get --ip 192.168.0.10 --name ExposureTime

# Set ExposureTime to 5000 (units depend on camera, often microseconds)
cargo run -p viva-camctl -- \
  set --ip 192.168.0.10 --name ExposureTime --value 5000

# Set PixelFormat by name
cargo run -p viva-camctl -- \
  set --ip 192.168.0.10 --name PixelFormat --value Mono8

For more, see: Registers & features.

“What are selectors and why do my changes seem to disappear?”

Many cameras use selectors to multiplex multiple logical settings onto one feature. Example:

  • GainSelector = All, Red, Green, Blue, …
  • Gain = value for the currently selected channel.

If you set Gain without first setting GainSelector, you might be modifying a different “row” than you expect.

Typical sequence:

cargo run -p viva-camctl -- \
  set --ip 192.168.0.10 --name GainSelector --value Red

cargo run -p viva-camctl -- \
  set --ip 192.168.0.10 --name Gain --value 5.0

See: Registers & features and the selectors_demo example in the viva-genicam crate.

“Do I need to care about the GenApi XML?”

For most applications, no:

  • You can use features by name and let viva-genapi handle the mapping.

You should look at the XML when:

  • A feature behaves differently from the SFNC / vendor documentation.
  • You are debugging selector or SwissKnife behaviour.
  • You are contributing to viva-genapi or genapi-xml.

See: GenApi XML tutorialand the crate chapters for viva-genapi-xml and viva-genapi when they are filled in.

“How do I save frames and look at them?”

With viva-camctl:

  • Use stream with an option like –count / –output (exact flags depend on the CLI):
cargo run -p viva-camctl -- \
  stream --ip 192.168.0.10 --iface 192.168.0.5 \
  --count 100 --output ./frames

This typically saves a sequence of frames in a simple format (e.g. raw, PGM/PPM) that you can inspect with:

  • Image viewers.
  • Python + NumPy + OpenCV.
  • Your own Rust tools.

See: Streaming.

“How do I generate documentation?”

  • mdBook (this book):
    • From the repository root:
cargo install mdbook  # if not already installed
mdbook build book
- The rendered HTML will be under book/book/.
  • Rust API docs:
    • From the repository root:
cargo doc --workspace --all-features
- The rendered HTML will be under target/doc/.

Many users publish these via GitHub Pages or another static host; see the repository CI configuration for details.

“Where should I report bugs or ask questions?”

If the problem is that a camera does not work, start with Reporting a camera we can’t open — it gives you two commands that collect everything we need, and explains why your device is the most valuable evidence this project can get.

Otherwise:

  • For bugs or feature requests, open an issue in the GitHub repository with:
    • A clear description of the problem.
    • Your OS, Rust version, and camera model.
    • A minimal reproduction if possible (CLI commands or small Rust snippet).
    • Relevant logs (e.g. RUST_LOG=debug output).
  • For questions that may be general (not specific to this project), link to:
    • The camera’s data sheet or GenICam XML snippet if relevant.
    • Any vendor tools you used to compare behaviour.

Good issues make it much easier to improve the crates for everyone.


Reporting a camera we can’t open

If viva-genicam cannot discover, open, or read your camera, that is worth reporting even if you have a workaround. This page explains what to send and why it matters more than it might look.

Why your camera is the evidence

This project is a clean-room implementation of the GenICam standards. We can test it against a specification, against a fake camera, and against a corpus of real vendor XML documents — and we do. None of that is the same as a device.

Real cameras are routinely non-conformant, internally inconsistent, or at odds with their own documentation, and the goal is to work with the hardware that exists, not the hardware the standard describes. When a camera and the spec disagree, we accommodate the camera. So a device we have never seen is the one thing that can settle a question no amount of reading will.

Two of the worst bugs this project has shipped — a camera that could not be opened at all in #35, and another in #45 — were each a single vendor XML construct we had never encountered. Both reached users before they reached us. Both were fixed from an XML document a reporter attached to the issue, and both documents are now in the test corpus, so that class of bug has something watching for it.

What to send

Two commands. Both deliberately stop before building the nodemap, so they work on a camera we cannot open — which is the only camera anyone reports.

# Everything: environment, interfaces, discovery, bootstrap registers, XML
viva-camctl report --ip <CAMERA-IP> --out viva-report.txt

# Just the GenApi XML
viva-camctl xml --ip <CAMERA-IP> --out camera.xml

If discovery itself is what fails, drop --ip — the report still records your interfaces and what discovery did or did not hear:

viva-camctl report --out viva-report.txt

The .txt extension is not cosmetic: GitHub rejects .xml attachments, so the bundle is written as text you can drag onto an issue. Zip the raw XML if you send it separately.

Both commands ship with the Python package too, so there is nothing to build from source:

pip install viva-genicam
viva-camctl report --ip <CAMERA-IP> --out viva-report.txt

What is in the bundle

Sections, in order:

SectionContents
Environmentviva-camctl version, host OS and architecture
Network interfacesEvery interface as the library sees it, with index and IPv4 addresses
DiscoveryEach camera that answered: IP, MAC, manufacturer, model, version, serial, user name
CameraWhich device the rest of the report is about
Bootstrap registersThe GVCP standard register block, decoded
GenApiXML size, schema version, node and feature counts, and any node that was dropped
GenApi XMLThe document itself

The interface list is there because of #57: an interface missing from that list is invisible to discovery no matter what the OS reports elsewhere, and that is not obvious from any other output.

A section that fails says so and the report continues — a camera that refuses the control channel still produces everything up to that point.

Before you attach it

The bundle describes your machine: interface names, every IPv4 address on the host, and the camera’s MAC and serial. None of it is secret, but if any of it is sensitive in your environment, edit the file before attaching — it is plain text, and we would rather have a redacted report than none. Say what you redacted so we do not read a blank as a missing value.

Use --no-xml to leave the GenApi document out, or --stdout to look at the bundle before writing it anywhere.

Where to send it

Open an issue at github.com/VitalyVorobyev/viva-genicam/issues. Useful alongside the bundle:

  • What you ran and what happened, quoted rather than paraphrased.
  • Output with RUST_LOG=debug, or viva-camctl -vv.
  • Whether a vendor tool works on the same camera and host — that separates a library bug from a network or camera configuration problem.
  • A packet capture, if you can take one. tcpdump -i <iface> -w capture.pcap udp port 3956 covers control traffic. A capture settled TC-09, a wire question we had been unable to answer from documentation alone.

What happens to it

The XML goes into the vendor corpus — a set of real device descriptions that a scheduled job parses and evaluates, building a full nodemap from each and exercising every node. Adding yours means the construct that broke your camera is watched from then on.

The corpus job runs weekly and on demand rather than on every pull request: the documents are fetched from third-party repositories, and an upstream rename or a network hiccup must not be able to block an unrelated merge. So a fix will not appear on the day you report it, but a regression will not go unnoticed either.

The documents are vendor copyright, published for interoperability by third-party projects, so the repository fetches them rather than redistributing them; the fetch script is scripts/fetch-xml-corpus.sh. Contributed documents are fetched from the issue you attach them to — which is why those issue threads are kept rather than closed quickly.

You can run the same check against your own camera without waiting for us. Point VIVA_GENICAM_XML_CORPUS at a directory holding your XML:

mkdir -p ~/mycorpus
viva-camctl xml --ip <CAMERA-IP> --out ~/mycorpus/mycam.xml

git clone https://github.com/VitalyVorobyev/viva-genicam && cd viva-genicam
VIVA_GENICAM_XML_CORPUS=~/mycorpus \
  cargo test -p viva-genapi-xml --test vendor_corpus -- --nocapture   # parses
VIVA_GENICAM_XML_CORPUS=~/mycorpus \
  cargo test -p viva-genapi     --test vendor_corpus -- --nocapture   # + evaluates

The second stage is the one that matters. Parsing a document only proves the XML is well-formed; building a nodemap from it and evaluating every node is what exercises the formula language, the address model and the numeric codecs — where the defects behind #35 all lived, invisible to the parser.

API reference

The API reference is generated with cargo doc and published alongside this book. Note that rustdoc uses the crate name, with underscores — viva_gige, not viva-gige.

Public API

Supporting crates

The Python API is documented separately in Python bindings → API reference; it does not appear in rustdoc.

Glossary

TermDefinition
GenICamGeneric Interface for Cameras – an EMVA standard for camera control
GenApiGeneric API – the XML-based feature description layer of GenICam
GenCPGeneric Control Protocol – transport-agnostic register read/write
GVCPGigE Vision Control Protocol – UDP-based control channel
GVSPGigE Vision Streaming Protocol – UDP-based image data channel
SFNCStandard Feature Naming Convention – standard camera feature names
PFNCPixel Format Naming Convention – standard pixel format codes
CCPControl Channel Privilege – exclusive camera access token
GenTLGeneric Transport Layer – shared library interface for camera transport
NodeMapIn-memory representation of GenApi features parsed from XML
RegisterIoTrait abstracting register read/write over any transport

License

This project is licensed under the MIT License.

See the LICENSE file in the repository root for the full text.