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
resendsstatistic is always zero and means “not implemented” rather than “none were needed”. Watchdrops. - 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
| Concept | Crate | Responsibility |
|---|---|---|
| GenCP (encode/decode, status) | viva-gencp | Message formats, errors, helpers for control-path operations |
| GVCP/GVSP (GigE Vision) | viva-gige | Discovery, control channel, streaming engine, resend/MTU/delay, events/actions |
| GenApi XML loader | viva-genapi-xml | Fetch XML via control path and parse schema‑lite into an internal representation |
| NodeMap & evaluation | viva-genapi | Node types (incl. SwissKnife), dependency resolution, selector routing, value get/set |
| Public façade | viva-genicam | End‑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.
6) What to read next
- 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
- Discovery (
viva-gige): bind to NIC → broadcast GVCP discovery → parse replies. - Connect: establish control channel (UDP) and prepare stream endpoints if needed.
- GenApi XML (
viva-genapi-xml): read address from device registers → fetch XML → parse to IR. - NodeMap (
viva-genapi): build nodes, resolve links (Includes, Pointers, Selectors), set defaults. - 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.
- Direct nodes read/write underlying registers via
- 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(ordebug,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-genapiby implementing the evaluation trait and wiring dependencies. - Add transports as new
viva-*crates behind a trait the facade can select at runtime. - Keep
viva-genicamthin: 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.
| Crate | Role / responsibility | Primary audience |
|---|---|---|
viva-genicam | High-level facade combining transport + GenApi | End-users — start here |
viva-camctl | CLI: discovery, features, streaming, diagnostics, IP config | End-users, ops, CI scripts |
viva-pygenicam | Python bindings (PyO3). Own workspace, own Cargo.lock | Python users |
viva-genapi | NodeMap, feature access, SwissKnife, selectors, access predicates | End-users & contributors |
viva-genapi-xml | Load GenICam XML from device or disk, parse into XmlModel | Contributors (XML / SFNC work) |
viva-gige | GigE Vision transport: GVCP control + GVSP streaming | End-users & contributors |
viva-u3v | USB3 Vision transport over libusb | End-users & contributors |
viva-gencp | GenCP message encode/decode, shared by both transports | Contributors, protocol nerds |
viva-pfnc | Pixel Format Naming Convention tables | Contributors |
viva-sfnc | Standard Feature Naming Convention constants | Contributors |
viva-zenoh-api | Message payloads and topic names shared with Viva Studio; plain serde types that do not link Zenoh | Contributors |
viva-service | Zenoh bridge: GigE cameras → Viva Studio | Integrators |
viva-service-u3v | Zenoh bridge: U3V cameras → Viva Studio | Integrators |
viva-fake-gige | In-process fake GigE camera for tests and demos | Everyone |
viva-fake-u3v | In-process fake U3V camera for tests | Contributors |
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-gigeknows how to talk UDP to a GigE Vision device (discovery, register access, image packets, stats, …).viva-gencpprovides the GenCP building blocks used on the control path.viva-genapi-xmlfetches and parses the GenApi XML that describes the device’s features.viva-genapiturns that XML into a NodeMap you can read/write, including SwissKnife expressions and selector-dependent features.viva-genicamstitches all of the above into a reasonably ergonomic API.viva-camctlexposes common workflows from genicam ascargo 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:
- GenCP: viva-gencp– control protocol building blocks.
- GigE Vision transport:
viva-gige– discovery, streaming, events, actions. - GenApi core & NodeMap:
viva-genapi– evaluating features, including SwissKnife.
If you’re reading this for the first time, a good path is:
- Skim this page.
- Work through the tutorials.
- 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
| Item | Purpose |
|---|---|
OpCode | ReadRegister, WriteRegister, ReadMem, WriteMem |
StatusCode | Transport-neutral status, with Unknown(u16) for anything not in the table |
CommandHeader / AckHeader | The 8-byte headers |
GenCpCmd / GenCpAck | Header plus payload |
encode_cmd / decode_ack | The two functions you call |
GenCpError | Decode failures |
HEADER_SIZE, PENDING_ACK_COMMAND | 8 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.
| Code | Meaning | Retry? |
|---|---|---|
Success | Completed | — |
NotImplemented | Device does not implement this command | No |
InvalidParameter | Parameter invalid or out of range | No |
InvalidAddress | No such address on the device | No |
WriteProtect | Register is read-only | No — permanent |
BadAlignment | Access not aligned as the transport requires | No |
AccessDenied | Refused this write: a GenApi lock, or control privilege not held | Only after fixing the cause |
Busy | Device busy | Yes — the only one worth retrying |
GenericError | Device 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 id — PENDING_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 streamingviva-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
| Module | Contents |
|---|---|
gvcp | discover, discover_on_interface, discover_all, force_ip, DeviceInfo, GigeDevice, GigeError |
gvsp | Packet parsing, frame reassembly, StreamDest, StreamConfig, chunk extraction |
nic | Iface — interface enumeration and selection |
action | send_action, ActionParams, AckSummary |
message | The event/message channel |
stats | StreamStats and its accumulator |
time | TimeSync — 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("-"),
);
}
| Function | Scans |
|---|---|
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_destinationpoints the device at a host socket;viva_genicam::EventStreampresents the result. - Actions are host-to-many-devices:
send_actionbroadcasts an action command so several cameras trigger together, optionally at a scheduled timestamp.AckSummaryreports 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::buildreads the register back throughGigeDevice::get_stream_packet_sizeand puts the effective size inStreamParams, 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_tofails. An explicitly configured size above 65 535 is refused rather than truncated:GevSCPSPacketSizeholds 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-gencp— the message layer GVCP carriesviva-genapi— the NodeMap above this transport- Tutorials: Discovery, Registers, Streaming
- Networking Guide — MTU, firewalls, link-local
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
| Variant | GenICam name |
|---|---|
Node::Integer | Integer, IntReg, MaskedIntReg |
Node::Float | Float, FloatReg |
Node::Enum | Enumeration |
Node::Boolean | Boolean |
Node::Command | Command |
Node::Category | Category |
Node::SwissKnife | SwissKnife, IntSwissKnife |
Node::Converter | Converter |
Node::IntConverter | IntConverter |
Node::String | StringReg |
Node::Register | Register (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:
| Method | Returns |
|---|---|
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:
| Variant | Means |
|---|---|
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 / UnknownVariable | A SwissKnife formula failed to parse, evaluate, or resolve a reference |
EnumValueUnknown / EnumNoSuchEntry | Raw value maps to no entry, or no entry has that name |
BadIndirectAddress | pAddress resolved to something impossible |
BitfieldOutOfRange / ValueTooWide | Bitfield 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 makesNullIoand 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
- GenApi XML tutorial — where the document comes from
- Registers & features — the same thing from an application’s side
viva-gige— the transport that backsGigeRegisterIo
Tutorials
This section walks you through typical workflows step by step.
The focus is a GigE Vision camera accessed over Ethernet, using:
- The
viva-camctlCLI for quick experiments and ops work. - The
viva-genicamcrate 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.
Recommended path
If you are new to the project, the recommended reading order is:
-
Discovery
Find cameras on your network, verify that discovery works, and understand basic NIC and firewall requirements. -
Registers & features
Read and write GenApi features (e.g.ExposureTime), understand selectors such asGainSelector, and learn when you might need raw registers. -
GenApi XML
Fetch the GenICam XML from a device, inspect it, and see how it maps to the NodeMap used byviva-genapi. -
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.tomlfor 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-camctlCLI 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:
| Function | Scans |
|---|---|
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.5oreth0) →--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:
- Physical link — is the link LED lit on NIC, switch and camera? Try another cable or port.
- Subnets — host NIC and camera must share a subnet. Two NICs on the same subnet confuse routing; avoid it.
- 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.
- Multiple NICs — force the right one with
--iface <host-ip>, or disable the others temporarily to confirm that NIC selection is the problem. - Vendor tools — if the vendor’s viewer sees the camera and
viva-camctldoes 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
ExposureTimeorGain. - 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-camctlCLI 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-xmlparses the XML into anXmlModel.viva-genapibuilds aNodeMapfrom it and evaluates nodes on demand.viva-genicamandviva-camctlsit 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 —
ExposureAutois the usual culprit forExposureTime,GainAutoforGain.
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-xmlfetches 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:
- Reads the
GevFirstURLregister at0x0200. - Interprets the result as a URL saying where the document actually is —
usually
local:plus a memory address and length, in principle alsohttp://orfile://. - Reads those bytes.
- Hands the string to a GenApi implementation.
Two details that the specification permits and real devices use:
- If
GevFirstURLis empty or its document cannot be retrieved,GevSecondURLat0x0400is tried next. - The document is often ZIP-compressed.
viva-genapi-xmldecompresses 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,
pValuedelegation, 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 xmlorfetch_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
- On the control path (GVCP), the host configures the stream: destination IP and port, packet size, and the acquisition settings themselves.
- On
AcquisitionStart, the camera sends GVSP packets on the stream channel — a leader, the payload packets, and a trailer per frame. - 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 —
10040by default, changeable with--port. - Confirm the
--ifaceinterface 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 9000or1500before 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
dropsas the meaningful reliability signal, and know thatresendsis 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
| Feature | Status |
|---|---|
| GVCP discovery (broadcast on loopback) | Supported |
| GenCP register read/write (READREG, WRITEREG, READMEM, WRITEMEM) | Supported |
| Control Channel Privilege (CCP) | Supported |
| GenApi XML with SFNC features | Width, Height, PixelFormat, ExposureTime, Gain |
| GVSP frame streaming | Synthetic 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_xmlserves the GenApi document ZIP-compressed, which many vendors do and which exercises a decompression path that would otherwise never be tested.enforce_heartbeatmakes the camera actually revoke control privilege when no GVCP command arrives insideheartbeat_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
- Install & hello-camera — install the wheel, run the self-contained fake-camera demo.
- Discovery — enumerate GigE and U3V cameras, restrict to one NIC, auto-detect interfaces.
- Control & introspection — read and write features, walk the NodeMap, discover which features apply.
- 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.chunksis not surfaced;ChunkConfigandconfigure_chunksare Rust-only. - Events. There is no binding for the message channel or
EventStream. - Time sync.
time_calibrateis not exposed, which also meansframe.ts_hosthas 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 fromviva-camctlbut 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:
| OS | Arch | Python |
|---|---|---|
| Linux (manylinux_2_28) | x86_64 | 3.9+ (abi3) |
| macOS | arm64 | 3.9+ (abi3) |
| Windows | x86_64 | 3.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)
| Exception | When |
|---|---|
GenApiError | Nodemap evaluation: unknown feature, value out of range, predicate failed |
TransportError | GVCP/USB register read or write failed |
ParseError | User-supplied value couldn’t be parsed per the node’s type |
MissingChunkFeatureError | Chunk selector not present in the camera’s XML |
UnsupportedPixelFormatError | No 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,FrameorFrameStreamfrom a thread other than the one that created it raisespyo3_runtime.PanicException, which subclassesBaseException— so evenexcept Exception:misses it. These objects areunsendable; 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 namekind—"Integer","Float","Enumeration","Boolean","Command","Category","SwissKnife","Converter","IntConverter","StringReg"access—"RO","RW","WO", orNone(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 format | Array shape | dtype |
|---|---|---|
| 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,) raw | uint8 |
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
- Open Network & Internet Settings → Change adapter options.
- Right-click the NIC used for cameras → Properties.
- Select Internet Protocol Version 4 (TCP/IPv4) → Properties.
- 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).
- 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:
- Select the camera NIC (e.g. USB Ethernet).
- Set “Configure IPv4” to “Manually”.
- Enter:
- IP address: 192.168.0.5
- Subnet mask: 255.255.255.0
- Leave router/gateway empty for a dedicated camera network.
⸻
3. Link-local (APIPA) cameras
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:
| Item | Value |
|---|---|
| IPv4 link-local network | 169.254.0.0/16 |
| Directed broadcast for that network | 169.254.255.255 |
| GVCP port on the camera | UDP 3956 |
Yours — substitute your own:
| Item | Where to get it |
|---|---|
| Host interface name | ip -brief address (Linux) |
| Host link-local address | Let the OS assign one, or pick an unused 169.254.x.y |
| Camera address | Whatever viva-camctl list reports; it can change between sessions |
| firewalld zone | firewall-cmd --get-active-zones |
| GVSP destination port | 10040 unless you pass --port to viva-camctl stream |
3.2. Giving the host a link-local address (Linux)
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
GevSCPSPacketSizeor similar feature to a value below the path MTU (e.g. 8192 for MTU 9000). You can useviva-camctl setor--packet-sizeonstream. - 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:
- Try slightly increasing the inter-packet delay.
- 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.
⸻
- 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 errorsGigeError– GVCP/GVSP transport errorsGenApiError– node evaluation and register I/O errorsGenCpError– GenCP protocol encoding errorsXmlError– 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
rustfmtdefaults - Keep
clippywarnings 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:
- Physical link
- Are the link LEDs lit on camera, NIC, and switch?
- Try a different cable or port.
- 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.
- 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).
- 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:
| Section | Contents |
|---|---|
| Environment | viva-camctl version, host OS and architecture |
| Network interfaces | Every interface as the library sees it, with index and IPv4 addresses |
| Discovery | Each camera that answered: IP, MAC, manufacturer, model, version, serial, user name |
| Camera | Which device the rest of the report is about |
| Bootstrap registers | The GVCP standard register block, decoded |
| GenApi | XML size, schema version, node and feature counts, and any node that was dropped |
| GenApi XML | The 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, orviva-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 3956covers 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
viva_genicam— the facade. Start here.viva_genapi—NodeMap, node evaluation,RegisterIoviva_genapi_xml— GenICam XML →XmlModelviva_gige— GVCP/GVSP transportviva_u3v— USB3 Vision transportviva_gencp— GenCP message primitives
Supporting crates
viva_pfnc— Pixel Format Naming Conventionviva_sfnc— Standard Feature Naming Conventionviva_zenoh_api— wire types shared with Viva Studioviva_camctl— the CLI, as a libraryviva_fake_gige— the in-process fake camera
The Python API is documented separately in Python bindings → API reference; it does not appear in rustdoc.
Glossary
| Term | Definition |
|---|---|
| GenICam | Generic Interface for Cameras – an EMVA standard for camera control |
| GenApi | Generic API – the XML-based feature description layer of GenICam |
| GenCP | Generic Control Protocol – transport-agnostic register read/write |
| GVCP | GigE Vision Control Protocol – UDP-based control channel |
| GVSP | GigE Vision Streaming Protocol – UDP-based image data channel |
| SFNC | Standard Feature Naming Convention – standard camera feature names |
| PFNC | Pixel Format Naming Convention – standard pixel format codes |
| CCP | Control Channel Privilege – exclusive camera access token |
| GenTL | Generic Transport Layer – shared library interface for camera transport |
| NodeMap | In-memory representation of GenApi features parsed from XML |
| RegisterIo | Trait 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.