Skip to main content

viva_gige/
gvcp.rs

1//! GVCP control plane utilities.
2
3use std::collections::HashMap;
4use std::io::Cursor;
5use std::net::{IpAddr, Ipv4Addr, SocketAddr};
6use std::time::Duration;
7
8use bytes::{Buf, BufMut, Bytes, BytesMut};
9use fastrand::Rng;
10use if_addrs::{IfAddr, get_if_addrs};
11use thiserror::Error;
12use tokio::net::UdpSocket;
13use tokio::task::JoinSet;
14use tokio::time;
15use tracing::{debug, info, trace, warn};
16use viva_gencp::{AckHeader, CommandFlags, GenCpAck, OpCode, StatusCode, decode_ack};
17
18use crate::nic::{self, Iface};
19
20/// GVCP protocol constants grouped by semantic area.
21pub mod consts {
22    use std::time::Duration;
23
24    /// GVCP control port as defined by the GigE Vision specification (section 7.3).
25    pub const PORT: u16 = 3956;
26    /// Opcode of the discovery command.
27    pub const DISCOVERY_COMMAND: u16 = 0x0002;
28    /// Opcode of the discovery acknowledgement.
29    pub const DISCOVERY_ACK: u16 = 0x0003;
30    /// Opcode of the FORCEIP command.
31    pub const FORCEIP_COMMAND: u16 = 0x0004;
32    /// Opcode of the FORCEIP acknowledgement.
33    pub const FORCEIP_ACK: u16 = 0x0005;
34    /// Opcode for requesting packet resends.
35    pub const PACKET_RESEND_COMMAND: u16 = 0x0040;
36    /// Opcode of the packet resend acknowledgement.
37    pub const PACKET_RESEND_ACK: u16 = 0x0041;
38    /// Opcode of the PENDING_ACK acknowledgement (GigE Vision 1.2, section 18.5).
39    ///
40    /// A device that cannot complete a command within the controller's timeout
41    /// answers with this instead of the real acknowledgement, asking for more
42    /// time. It is not a GenCP opcode — the U3V side of GenCP signals the same
43    /// condition with status `0x8006` — so it is handled in the GVCP layer.
44    pub const PENDING_ACK: u16 = 0x0089;
45
46    // ── Event channel and action commands ───────────────────────────────
47    //
48    // These four opcodes are the ones a device sends *to* us on the message
49    // channel, plus the two we broadcast for actions. They live here rather
50    // than in `action`/`message` because keeping every GVCP opcode in one
51    // table is what makes a collision visible: `ACTION_COMMAND` was 0x0080 —
52    // `READ_REGISTER` — for as long as it had its own private constant.
53    // GigE Vision 2.0 section 18; corroborated by Wireshark's `packet-gvcp.c`
54    // (`GVCP_ACTION_CMD`, `GVCP_EVENT_CMD`, `GVCP_EVENTDATA_CMD`) and, for the
55    // register commands it shadowed, `../aravis/src/arvgvcpprivate.h`.
56
57    /// Opcode of the event command sent by a device on the message channel.
58    pub const EVENT_COMMAND: u16 = 0x00C0;
59    /// Opcode of the acknowledgement a controller returns for an `EVENT_CMD`.
60    pub const EVENT_ACK: u16 = 0x00C1;
61    /// Opcode of the event command that carries device-specific event data.
62    pub const EVENTDATA_COMMAND: u16 = 0x00C2;
63    /// Opcode of the acknowledgement returned for an `EVENTDATA_CMD`.
64    pub const EVENTDATA_ACK: u16 = 0x00C3;
65    /// Opcode of the action command.
66    pub const ACTION_COMMAND: u16 = 0x0100;
67    /// Opcode of the action acknowledgement.
68    pub const ACTION_ACK: u16 = 0x0101;
69
70    /// Size of one event entry in an `EVENT_CMD` with 16-bit block IDs.
71    pub const EVENT_ENTRY: usize = 16;
72    /// Size of one event entry in an `EVENT_CMD` with 64-bit block IDs.
73    ///
74    /// GigE Vision 2.0 extended block IDs, signalled by bit 4 of the GVCP
75    /// flags byte.
76    pub const EVENT_ENTRY_EXTENDED: usize = 24;
77
78    /// Current IP configuration flags register.
79    ///
80    /// Bit 2 = DHCP, bit 1 = persistent IP, bit 0 = LLA.
81    pub const CURRENT_IP_CONFIG: u64 = 0x0014;
82
83    /// Persistent IP address register (4 bytes at the end of a 16-byte block).
84    pub const PERSISTENT_IP_ADDRESS: u64 = 0x064C;
85    /// Persistent subnet mask register.
86    pub const PERSISTENT_SUBNET_MASK: u64 = 0x065C;
87    /// Persistent default gateway register.
88    pub const PERSISTENT_DEFAULT_GATEWAY: u64 = 0x066C;
89
90    /// Address of the Control Channel Privilege (CCP) register.
91    ///
92    /// A controller must write `CONTROL_PRIVILEGE` to this register before the
93    /// device accepts stream configuration or acquisition commands.
94    pub const CONTROL_CHANNEL_PRIVILEGE: u64 = 0x0a00;
95    /// CCP value claiming exclusive control.
96    pub const CCP_CONTROL: u32 = 1 << 1;
97    /// CCP value indicating an exclusive owner.
98    pub const CCP_EXCLUSIVE: u32 = 1 << 0;
99    /// Bits of the CCP register that mean "this application is the controller".
100    pub const CCP_CONTROLLER_BITS: u32 = CCP_CONTROL | CCP_EXCLUSIVE;
101
102    /// Heartbeat timeout register (`GevHeartbeatTimeout`), in milliseconds.
103    ///
104    /// A device that has granted control privilege releases it again if it
105    /// receives no GVCP command from the controlling application within this
106    /// window. GVSP image traffic does not count — a stream can be running at
107    /// full rate while the control channel times out underneath it.
108    pub const HEARTBEAT_TIMEOUT: u64 = 0x0938;
109
110    // ── Message channel bootstrap registers ─────────────────────────────
111    //
112    // These sit in the 0x0B00 block, next to CCP at 0x0A00 and the stream
113    // channels at 0x0D00. They previously read 0x0900_0200 / 0x0900_0204,
114    // which is not a bootstrap address at all: 0x0900 is
115    // `GevNumberOfMessageChannels`, and the low half was being used as if it
116    // were a base. Every event-channel write therefore landed ~150 MB into
117    // the device's register space. Wireshark's `packet-gvcp.c`
118    // (`GVCP_MC_DESTINATION_PORT`, `GVCP_MC_DESTINATION_ADDRESS`) gives the
119    // real values, and the CCP and stream-channel addresses we already had
120    // right corroborate the scheme.
121
122    /// Number of message channels the device implements (`GevNumberOfMessageChannels`).
123    pub const NUMBER_OF_MESSAGE_CHANNELS: u64 = 0x0000_0900;
124    /// Message channel destination port register (`GevMCP`).
125    ///
126    /// A 32-bit register; the UDP port occupies the low 16 bits.
127    pub const MESSAGE_DESTINATION_PORT: u64 = 0x0000_0B00;
128    /// Message channel destination address register (`GevMCDA`).
129    pub const MESSAGE_DESTINATION_ADDRESS: u64 = 0x0000_0B10;
130    /// Message channel transmission timeout in milliseconds (`GevMCTT`).
131    pub const MESSAGE_CHANNEL_TIMEOUT: u64 = 0x0000_0B14;
132    /// Message channel retry count (`GevMCRC`).
133    pub const MESSAGE_CHANNEL_RETRY_COUNT: u64 = 0x0000_0B18;
134
135    /// Maximum number of bytes we read per GenCP `ReadMem` operation.
136    pub const GENCP_MAX_BLOCK: usize = 512;
137    /// Additional bytes that accompany a GenCP `WriteMem` block.
138    pub const GENCP_WRITE_OVERHEAD: usize = 8;
139
140    /// Default timeout for control transactions.
141    pub const CONTROL_TIMEOUT: Duration = Duration::from_millis(500);
142    /// Maximum number of automatic retries for a control transaction.
143    pub const MAX_RETRIES: usize = 4;
144    /// Maximum number of consecutive PENDING_ACKs honoured for one command.
145    ///
146    /// A device is free to keep asking for more time; this bounds a
147    /// misbehaving one that never finishes.
148    pub const MAX_PENDING_ACKS: usize = 100;
149    /// Ceiling on the extension a single PENDING_ACK may request.
150    ///
151    /// The field is 16-bit milliseconds, so the wire maximum is ~65 s. Cap it
152    /// well below that: an honest device asks for tens or hundreds of
153    /// milliseconds, and this keeps a garbage value from hanging the caller.
154    pub const MAX_PENDING_ACK_WAIT: Duration = Duration::from_secs(10);
155    /// Base delay used for retry backoff.
156    pub const RETRY_BASE_DELAY: Duration = Duration::from_millis(20);
157    /// Upper bound for the random jitter added to the retry delay (inclusive).
158    pub const RETRY_JITTER: Duration = Duration::from_millis(10);
159
160    /// Maximum number of bytes captured while listening for discovery responses.
161    pub const DISCOVERY_BUFFER: usize = 2048;
162
163    /// Base register for stream channel 0 (GigE Vision bootstrap register map).
164    ///
165    /// The GigE Vision specification defines stream channel bootstrap registers
166    /// starting at 0x0d00. Note: some cameras may use different offsets declared
167    /// in their GenICam XML (e.g. SFNC `GevSCDA` nodes). The bootstrap offsets
168    /// here match the aravis implementation and the GigE Vision 2.x standard.
169    pub const STREAM_CHANNEL_BASE: u64 = 0x0d00;
170    /// Stride in bytes between successive stream channel blocks.
171    pub const STREAM_CHANNEL_STRIDE: u64 = 0x40;
172    /// Offset for `GevSCPHostPort` within a stream channel block.
173    pub const STREAM_DESTINATION_PORT: u64 = 0x00;
174    /// Offset for `GevSCPSPacketSize` within a stream channel block.
175    pub const STREAM_PACKET_SIZE: u64 = 0x04;
176    /// Offset for `GevSCPD` (packet delay) within a stream channel block.
177    pub const STREAM_PACKET_DELAY: u64 = 0x08;
178    /// Offset for `GevSCDA` (stream destination IP address) within a stream channel block.
179    pub const STREAM_DESTINATION_ADDRESS: u64 = 0x18;
180}
181
182/// Public alias for the GVCP well-known port.
183pub use consts::PORT as GVCP_PORT;
184
185/// The bits of `GevSCPSPacketSize` that hold the packet size.
186///
187/// Bit 31 fires a test packet and bit 30 sets do-not-fragment; bits 29-16 are
188/// reserved. Masking matters on the read side as much as the write side — a
189/// device that leaves the do-not-fragment bit set would otherwise read back as
190/// a packet size of over a billion.
191pub const STREAM_PACKET_SIZE_MASK: u32 = 0xFFFF;
192
193/// `GevSCPSPacketSize` bit 31: emit one test packet of the requested size.
194///
195/// The only way to discover that a size *both endpoints accept* cannot cross
196/// the link between them — a register read reports what the device stored, not
197/// what the network will carry
198/// ([#112](https://github.com/VitalyVorobyev/viva-genicam/issues/112)).
199pub const SCPS_FIRE_TEST_PACKET: u32 = 0x8000_0000;
200
201/// `GevSCPSPacketSize` bit 30: set do-not-fragment on transmitted packets.
202///
203/// Paired with the test packet so a path that would *fragment* the datagram
204/// drops it instead. Without it a probe can succeed on a link that then
205/// delivers reassembled fragments, which is slower than the smaller size it
206/// talked us out of.
207pub const SCPS_DO_NOT_FRAGMENT: u32 = 0x4000_0000;
208
209/// GVCP request header.
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub struct GvcpRequestHeader {
212    /// Request flags (acknowledgement, broadcast).
213    pub flags: CommandFlags,
214    /// Raw command/opcode value.
215    pub command: u16,
216    /// Payload length in bytes.
217    pub length: u16,
218    /// Request identifier.
219    pub request_id: u16,
220}
221
222/// GVCP command message key value (first byte of every GVCP command packet).
223const GVCP_CMD_KEY: u8 = 0x42;
224
225impl GvcpRequestHeader {
226    /// Encode the header into a `Bytes` buffer ready to be transmitted.
227    ///
228    /// Uses proper GVCP wire format: byte 0 = `0x42` (command key),
229    /// byte 1 = flags byte (bit 0 = ACK_REQUIRED, bit 4 = BROADCAST).
230    pub fn encode(self, payload: &[u8]) -> Bytes {
231        let mut buf = BytesMut::with_capacity(viva_gencp::HEADER_SIZE + payload.len());
232        // GVCP command header: key byte + flags byte (not a u16 flags field).
233        buf.put_u8(GVCP_CMD_KEY);
234        buf.put_u8(self.gvcp_flags_byte());
235        buf.put_u16(self.command);
236        buf.put_u16(self.length);
237        buf.put_u16(self.request_id);
238        buf.extend_from_slice(payload);
239        buf.freeze()
240    }
241
242    /// Convert `CommandFlags` to the single-byte GVCP flag field.
243    ///
244    /// Bit 4 is overloaded by the specification: it means "allow broadcast
245    /// acknowledge" on `DISCOVERY_CMD` and "64-bit block IDs" on
246    /// `PACKETRESEND_CMD`/`EVENT_CMD`/`EVENTDATA_CMD`. We only ever set it for
247    /// the former and only ever read it for the latter, so one mapping covers
248    /// both.
249    fn gvcp_flags_byte(&self) -> u8 {
250        let mut byte = 0u8;
251        if self.flags.contains(CommandFlags::ACK_REQUIRED) {
252            byte |= 0x01;
253        }
254        if self.flags.contains(CommandFlags::BROADCAST) {
255            byte |= 0x10;
256        }
257        if self.flags.contains(CommandFlags::SCHEDULED_ACTION) {
258            byte |= 0x80;
259        }
260        byte
261    }
262}
263
264/// GVCP acknowledgement header wrapper.
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266pub struct GvcpAckHeader {
267    /// Status reported by the device.
268    pub status: StatusCode,
269    /// Raw command/opcode value.
270    pub command: u16,
271    /// Payload length in bytes.
272    pub length: u16,
273    /// Identifier of the answered request.
274    pub request_id: u16,
275}
276
277impl From<AckHeader> for GvcpAckHeader {
278    fn from(value: AckHeader) -> Self {
279        Self {
280            status: value.status,
281            command: value.opcode.ack_code(),
282            length: value.length,
283            request_id: value.request_id,
284        }
285    }
286}
287
288/// Errors that can occur when operating the GVCP control path.
289#[derive(Debug, Error)]
290pub enum GigeError {
291    #[error("io: {0}")]
292    Io(#[from] std::io::Error),
293    #[error("protocol: {0}")]
294    Protocol(String),
295    #[error("timeout waiting for acknowledgement")]
296    Timeout,
297    #[error("GenCP: {0}")]
298    GenCp(#[from] viva_gencp::GenCpError),
299    #[error("device reported status {0}")]
300    Status(StatusCode),
301}
302
303/// Information returned by GVCP discovery packets.
304#[derive(Debug, Clone, PartialEq, Eq)]
305pub struct DeviceInfo {
306    pub ip: Ipv4Addr,
307    pub mac: [u8; 6],
308    pub model: Option<String>,
309    pub manufacturer: Option<String>,
310    /// Device version string (Discovery ACK offset 136).
311    pub version: Option<String>,
312    /// Serial number as printed on the device (Discovery ACK offset 216).
313    pub serial: Option<String>,
314    /// User-programmable device name (Discovery ACK offset 232).
315    pub user_name: Option<String>,
316}
317
318impl DeviceInfo {
319    /// A minimal record for a device addressed directly by IP.
320    ///
321    /// Used when the caller names a camera by address and there is no
322    /// Discovery ACK to populate the identity fields.
323    pub fn from_ip(ip: Ipv4Addr) -> Self {
324        Self {
325            ip,
326            mac: [0; 6],
327            model: None,
328            manufacturer: None,
329            version: None,
330            serial: None,
331            user_name: None,
332        }
333    }
334
335    /// Format the MAC address as `AA:BB:CC:DD:EE:FF`.
336    pub fn mac_string(&self) -> String {
337        self.mac
338            .iter()
339            .map(|byte| format!("{byte:02X}"))
340            .collect::<Vec<_>>()
341            .join(":")
342    }
343}
344
345/// Discover GigE Vision devices on the local network by broadcasting a GVCP discovery command.
346pub async fn discover(timeout: Duration) -> Result<Vec<DeviceInfo>, GigeError> {
347    discover_impl(timeout, None, false).await
348}
349
350/// Discover devices only on the specified interface name.
351/// Discover devices only on the specified interface name.
352///
353/// When a user explicitly names an interface (including loopback like `lo0`),
354/// it is always included — the loopback filter only applies to the unfiltered
355/// [`discover`] call.
356pub async fn discover_on_interface(
357    timeout: Duration,
358    interface: &str,
359) -> Result<Vec<DeviceInfo>, GigeError> {
360    discover_impl(timeout, Some(interface), true).await
361}
362
363/// Discover devices on all interfaces including loopback.
364///
365/// This is useful for testing with simulated cameras (e.g. `arv-fake-gv-camera`)
366/// bound to `127.0.0.1`.
367pub async fn discover_all(timeout: Duration) -> Result<Vec<DeviceInfo>, GigeError> {
368    discover_impl(timeout, None, true).await
369}
370
371/// Send a FORCEIP command to temporarily assign an IP address to a device.
372///
373/// FORCEIP is a broadcast command that targets a device by its MAC address.
374/// The assigned IP is temporary — it does not survive a power cycle. Use
375/// [`GigeDevice::write_persistent_ip`] + [`GigeDevice::enable_persistent_ip`]
376/// for permanent configuration.
377///
378/// FORCEIP payload layout (56 bytes, big-endian):
379/// ```text
380/// [0..2]   reserved
381/// [2..8]   target MAC address (6 bytes)
382/// [8..20]  reserved
383/// [20..24] static IP address
384/// [24..36] reserved
385/// [36..40] subnet mask
386/// [40..52] reserved
387/// [52..56] gateway
388/// ```
389pub async fn force_ip(
390    mac: [u8; 6],
391    ip: Ipv4Addr,
392    subnet: Ipv4Addr,
393    gateway: Ipv4Addr,
394    iface: Option<&Iface>,
395) -> Result<(), GigeError> {
396    // Build the 56-byte FORCEIP payload.
397    let payload = encode_forceip_payload(mac, ip, subnet, gateway);
398
399    let local_ip = match iface {
400        Some(iface) => iface
401            .ipv4()
402            .ok_or_else(|| GigeError::Protocol("interface lacks IPv4 address".into()))?,
403        None => Ipv4Addr::UNSPECIFIED,
404    };
405
406    let socket = UdpSocket::bind(SocketAddr::new(IpAddr::V4(local_ip), 0)).await?;
407    socket.set_broadcast(true)?;
408    let dest = SocketAddr::new(IpAddr::V4(Ipv4Addr::BROADCAST), consts::PORT);
409
410    let header = GvcpRequestHeader {
411        flags: CommandFlags::ACK_REQUIRED | CommandFlags::BROADCAST,
412        command: consts::FORCEIP_COMMAND,
413        length: payload.len() as u16,
414        request_id: 1,
415    };
416    let packet = header.encode(&payload);
417    info!(mac = ?mac, %ip, %subnet, %gateway, "sending FORCEIP command");
418    socket.send_to(&packet, dest).await?;
419
420    // Wait for FORCEIP_ACK.
421    let mut buf = vec![0u8; consts::DISCOVERY_BUFFER];
422    match time::timeout(consts::CONTROL_TIMEOUT, socket.recv_from(&mut buf)).await {
423        Ok(Ok((len, _src))) => {
424            if len < viva_gencp::HEADER_SIZE {
425                return Err(GigeError::Protocol("FORCEIP ack too short".into()));
426            }
427            let mut cursor = &buf[..];
428            let status = cursor.get_u16();
429            let command = cursor.get_u16();
430            if command != consts::FORCEIP_ACK {
431                return Err(GigeError::Protocol(format!(
432                    "unexpected FORCEIP ack opcode {command:#06x}"
433                )));
434            }
435            if status != 0 {
436                return Err(GigeError::Protocol(format!(
437                    "FORCEIP returned status {status:#06x}"
438                )));
439            }
440            info!(%ip, "FORCEIP accepted");
441            Ok(())
442        }
443        Ok(Err(e)) => Err(e.into()),
444        Err(_) => Err(GigeError::Timeout),
445    }
446}
447
448/// Encode the 56-byte FORCEIP payload.
449fn encode_forceip_payload(
450    mac: [u8; 6],
451    ip: Ipv4Addr,
452    subnet: Ipv4Addr,
453    gateway: Ipv4Addr,
454) -> Vec<u8> {
455    let mut buf = vec![0u8; 56];
456    // [0..2]   reserved
457    // [2..8]   MAC address
458    buf[2..8].copy_from_slice(&mac);
459    // [8..20]  reserved
460    // [20..24] IP address
461    buf[20..24].copy_from_slice(&ip.octets());
462    // [24..36] reserved
463    // [36..40] subnet mask
464    buf[36..40].copy_from_slice(&subnet.octets());
465    // [40..52] reserved
466    // [52..56] gateway
467    buf[52..56].copy_from_slice(&gateway.octets());
468    buf
469}
470
471async fn discover_impl(
472    timeout: Duration,
473    iface_filter: Option<&str>,
474    include_loopback: bool,
475) -> Result<Vec<DeviceInfo>, GigeError> {
476    let mut interfaces = Vec::new();
477    for iface in get_if_addrs()? {
478        let IfAddr::V4(v4) = iface.addr else {
479            continue;
480        };
481        if !include_loopback && v4.ip.is_loopback() {
482            continue;
483        }
484        if let Some(filter) = iface_filter
485            && iface.name != filter
486        {
487            continue;
488        }
489        interfaces.push((iface.name, v4));
490    }
491
492    if interfaces.is_empty() {
493        return Ok(Vec::new());
494    }
495
496    let mut join_set = JoinSet::new();
497    for (idx, (name, v4)) in interfaces.into_iter().enumerate() {
498        let request_id = 0x0100u16.wrapping_add(idx as u16);
499        let interface_name = name.clone();
500        join_set.spawn(async move {
501            let local_addr = SocketAddr::new(IpAddr::V4(v4.ip), 0);
502            let socket = match UdpSocket::bind(local_addr).await {
503                Ok(socket) => socket,
504                Err(err) => {
505                    warn!(%interface_name, local = %v4.ip, error = %err,
506                          "skipping interface: bind failed");
507                    return Vec::new();
508                }
509            };
510            // On loopback, broadcast is not supported on some platforms (macOS).
511            // Send unicast discovery directly to the local address instead.
512            let destination = if v4.ip.is_loopback() {
513                SocketAddr::new(IpAddr::V4(v4.ip), consts::PORT)
514            } else {
515                if let Err(err) = socket.set_broadcast(true) {
516                    warn!(%interface_name, local = %v4.ip, error = %err,
517                          "skipping interface: SO_BROADCAST failed");
518                    return Vec::new();
519                }
520                let broadcast = directed_broadcast(v4.ip, v4.netmask);
521                SocketAddr::new(IpAddr::V4(broadcast), consts::PORT)
522            };
523
524            let header = GvcpRequestHeader {
525                flags: CommandFlags::ACK_REQUIRED | CommandFlags::BROADCAST,
526                command: consts::DISCOVERY_COMMAND,
527                length: 0,
528                request_id,
529            };
530            let packet = header.encode(&[]);
531            info!(%interface_name, local = %v4.ip, dest = %destination, "sending GVCP discovery");
532            trace!(%interface_name, bytes = packet.len(), "GVCP discovery payload size");
533            if let Err(err) = socket.send_to(&packet, destination).await {
534                warn!(%interface_name, dest = %destination, error = %err,
535                      "skipping interface: discovery send failed");
536                return Vec::new();
537            }
538
539            let mut responses = Vec::new();
540            let mut buffer = vec![0u8; consts::DISCOVERY_BUFFER];
541            let timer = time::sleep(timeout);
542            tokio::pin!(timer);
543            loop {
544                tokio::select! {
545                    _ = &mut timer => break,
546                    recv = socket.recv_from(&mut buffer) => {
547                        // A receive error must not discard the cameras we have
548                        // already found on this interface. Windows in particular
549                        // reports WSAECONNRESET (10054) here when an earlier
550                        // broadcast drew an ICMP port-unreachable (#57).
551                        let (len, src) = match recv {
552                            Ok(v) => v,
553                            Err(err) => {
554                                debug!(%interface_name, error = %err,
555                                       "discovery receive failed; keeping results so far");
556                                break;
557                            }
558                        };
559                        info!(%interface_name, %src, "received GVCP response");
560                        trace!(%interface_name, bytes = len, "GVCP response length");
561                        if let Some(info) = parse_discovery_ack(&buffer[..len], request_id) {
562                            trace!(ip = %info.ip, mac = %info.mac_string(), "parsed discovery ack");
563                            responses.push(info);
564                        }
565                    }
566                }
567            }
568            responses
569        });
570    }
571
572    // One interface failing must never fail the whole call: a host commonly has
573    // a down NIC, a Hyper-V switch, or a VPN adapter alongside the one the
574    // camera is on.
575    let mut seen = HashMap::new();
576    while let Some(res) = join_set.join_next().await {
577        match res {
578            Ok(devices) => {
579                for dev in devices {
580                    seen.entry((dev.ip, dev.mac)).or_insert(dev);
581                }
582            }
583            Err(err) => warn!(error = %err, "discovery task failed"),
584        }
585    }
586
587    let mut devices: Vec<_> = seen.into_values().collect();
588    devices.sort_by_key(|d| d.ip);
589    Ok(devices)
590}
591
592/// Derive an IPv4 directed-broadcast address from an interface address and
593/// netmask.
594///
595/// On Linux an address added without an explicit `brd` value can make
596/// `getifaddrs(3)` report the address itself through the broadcast union. That
597/// turns discovery into a unicast request, notably for a manually configured
598/// `169.254.0.0/16` address. The netmask remains authoritative, so calculate
599/// the broadcast address ourselves instead of trusting that optional field.
600fn directed_broadcast(ip: Ipv4Addr, netmask: Ipv4Addr) -> Ipv4Addr {
601    Ipv4Addr::from(u32::from(ip) | !u32::from(netmask))
602}
603
604/// Decode one datagram received on the discovery socket.
605///
606/// Returns `None` — never an error — for anything that is not a usable
607/// Discovery ACK for us. The socket is bound to an ephemeral port on a
608/// broadcast network, so unrelated GVCP traffic is expected; treating it as
609/// fatal would discard the cameras already found on this interface (#57).
610fn parse_discovery_ack(buf: &[u8], expected_request: u16) -> Option<DeviceInfo> {
611    if buf.len() < viva_gencp::HEADER_SIZE {
612        trace!(len = buf.len(), "ignoring short GVCP datagram");
613        return None;
614    }
615    let mut header = buf;
616    let status = header.get_u16();
617    let command = header.get_u16();
618    let length = header.get_u16() as usize;
619    let request_id = header.get_u16();
620    if request_id != expected_request {
621        return None;
622    }
623    if command != consts::DISCOVERY_ACK {
624        debug!(
625            opcode = format_args!("{command:#06x}"),
626            "ignoring non-discovery GVCP ack"
627        );
628        return None;
629    }
630    if status != 0 {
631        debug!(
632            status = format_args!("{status:#06x}"),
633            "discovery ack reported a non-zero status"
634        );
635        return None;
636    }
637    if buf.len() < viva_gencp::HEADER_SIZE + length {
638        debug!(
639            declared = length,
640            actual = buf.len() - viva_gencp::HEADER_SIZE,
641            "ignoring truncated discovery payload"
642        );
643        return None;
644    }
645    let payload = &buf[viva_gencp::HEADER_SIZE..viva_gencp::HEADER_SIZE + length];
646    match parse_discovery_payload(payload) {
647        Ok(info) => Some(info),
648        Err(err) => {
649            debug!(error = %err, "ignoring unparsable discovery payload");
650            None
651        }
652    }
653}
654
655/// Parse a GigE Vision Discovery ACK payload (248 bytes).
656///
657/// The payload mirrors the device's bootstrap register block, so the MAC is
658/// split across `DeviceMACAddressHigh` (0x08, whose low half holds the top two
659/// octets) and `DeviceMACAddressLow` (0x0C) — six contiguous bytes at offset
660/// **10**, not 12. Cross-checked against Wireshark's `dissect_discovery_ack()`,
661/// which reads the MAC from `offset + 10` and the IP, manufacturer and model
662/// from 36, 72 and 104. Reported in #57 against a JAI FS-3200T-10GE-NNC, whose
663/// `00:0C:DF:06:5B:2F` was read as `DF:06:5B:2F:C0:00`.
664///
665/// | Offset | Size | Field                        |
666/// |--------|------|------------------------------|
667/// |      0 |    2 | Spec version major           |
668/// |      2 |    2 | Spec version minor           |
669/// |      4 |    4 | Device mode                  |
670/// |      8 |    2 | Reserved (MAC-high padding)  |
671/// |     10 |    6 | MAC address                  |
672/// |     16 |    4 | Supported IP config          |
673/// |     20 |    4 | Current IP config            |
674/// |     24 |   12 | Reserved                     |
675/// |     36 |    4 | Current IP address           |
676/// |     40 |   12 | Reserved                     |
677/// |     52 |    4 | Current subnet mask          |
678/// |     56 |   12 | Reserved                     |
679/// |     68 |    4 | Default gateway              |
680/// |     72 |   32 | Manufacturer name            |
681/// |    104 |   32 | Model name                   |
682/// |    136 |   32 | Device version               |
683/// |    168 |   48 | Manufacturer specific info   |
684/// |    216 |   16 | Serial number                |
685/// |    232 |   16 | User defined name            |
686fn parse_discovery_payload(payload: &[u8]) -> Result<DeviceInfo, GigeError> {
687    // Minimum size to reach past the current IP field. Everything after it is
688    // read leniently: a short-but-valid ACK should still yield a usable device
689    // rather than failing discovery on that interface.
690    if payload.len() < 40 {
691        return Err(GigeError::Protocol("discovery payload too small".into()));
692    }
693    let mut cursor = Cursor::new(payload);
694    let _spec_major = cursor.get_u16(); // 0
695    let _spec_minor = cursor.get_u16(); // 2
696    let _device_mode = cursor.get_u32(); // 4
697
698    // Only the two padding bytes of the MAC-high register precede the address.
699    cursor.advance(2); // 8..10
700
701    // MAC: 2 bytes from the high register + 4 from the low register.
702    let mut mac = [0u8; 6];
703    cursor.copy_to_slice(&mut mac); // 10..16
704
705    let _supported_ip_config = cursor.get_u32(); // 16
706    let _current_ip_config = cursor.get_u32(); // 20
707
708    // 12 bytes reserved before current IP.
709    cursor.advance(12); // 24..36
710    let ip = Ipv4Addr::from(cursor.get_u32()); // 36
711
712    // Everything past the IP is optional, so every step from here on has to
713    // tolerate the payload ending. Subnet and gateway are not retained.
714    skip(&mut cursor, 12 + 4); // 40..52 reserved, 52 subnet
715    skip(&mut cursor, 12 + 4); // 56..68 reserved, 68 gateway
716
717    // String fields. All optional: a device that truncates the payload still
718    // gives us an addressable camera.
719    let manufacturer = read_fixed_string(&mut cursor, 32); // 72
720    let model = read_fixed_string(&mut cursor, 32); // 104
721    let version = read_fixed_string(&mut cursor, 32); // 136
722    skip(&mut cursor, 48); // 168 manufacturer-specific info
723    let serial = read_fixed_string(&mut cursor, 16); // 216
724    let user_name = read_fixed_string(&mut cursor, 16); // 232
725
726    Ok(DeviceInfo {
727        ip,
728        mac,
729        manufacturer,
730        model,
731        version,
732        serial,
733        user_name,
734    })
735}
736
737/// Read a NUL-padded fixed-width string, or `None` if the payload ends early.
738fn read_fixed_string(cursor: &mut Cursor<&[u8]>, len: usize) -> Option<String> {
739    if cursor.remaining() < len {
740        return None;
741    }
742    let mut buf = vec![0u8; len];
743    cursor.copy_to_slice(&mut buf);
744    parse_string(&buf)
745}
746
747/// Advance past a field, stopping at the end of a truncated payload.
748fn skip(cursor: &mut Cursor<&[u8]>, len: usize) {
749    let n = len.min(cursor.remaining());
750    cursor.advance(n);
751}
752
753fn parse_string(bytes: &[u8]) -> Option<String> {
754    let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
755    let slice = &bytes[..end];
756    let s = String::from_utf8_lossy(slice).trim().to_string();
757    if s.is_empty() { None } else { Some(s) }
758}
759
760/// Outcome of waiting for one acknowledgement.
761///
762/// Mirrors the three cases the caller already distinguishes — a datagram, a
763/// socket error, and a deadline — so that PENDING_ACK handling can be factored
764/// out without changing the retry policy around it.
765enum AckRecv {
766    Received(usize),
767    Io(std::io::Error),
768    TimedOut,
769}
770
771/// Decode a GVCP PENDING_ACK, returning the request id it refers to and the
772/// extra time the device is asking for.
773///
774/// Returns `None` for anything that is not a PENDING_ACK, so the caller can
775/// hand the datagram to the normal GenCP decoder.
776///
777/// Layout per GigE Vision 1.2 section 18.5: the 8-byte GVCP acknowledgement
778/// header, then two reserved bytes and a 16-bit `time_to_completion` in
779/// milliseconds.
780///
781/// This diverges deliberately from aravis, whose
782/// `arv_gvcp_packet_get_pending_ack_timeout` reads all four payload bytes as a
783/// big-endian `u32`. The two agree whenever the reserved field is zero, which
784/// is what the specification requires of the device; reading the `u16` the
785/// specification actually defines is the safer of the two, because a device
786/// that leaves junk in the reserved field cannot then talk us into a wait
787/// three orders of magnitude too long. Callers clamp the result regardless.
788fn parse_pending_ack(buf: &[u8]) -> Option<(u16, Duration)> {
789    if buf.len() < viva_gencp::HEADER_SIZE {
790        return None;
791    }
792    if u16::from_be_bytes([buf[2], buf[3]]) != consts::PENDING_ACK {
793        return None;
794    }
795    let request_id = u16::from_be_bytes([buf[6], buf[7]]);
796    let payload = &buf[viva_gencp::HEADER_SIZE..];
797    // A truncated PENDING_ACK is still an unambiguous request for more time;
798    // grant the default rather than discarding it and resending the command.
799    let millis = match payload {
800        [_, _, hi, lo, ..] => u16::from_be_bytes([*hi, *lo]),
801        _ => return Some((request_id, consts::CONTROL_TIMEOUT)),
802    };
803    Some((request_id, Duration::from_millis(u64::from(millis))))
804}
805
806/// GVCP device handle.
807pub struct GigeDevice {
808    socket: UdpSocket,
809    remote: SocketAddr,
810    request_id: u16,
811    rng: Rng,
812}
813
814/// Stream negotiation outcome describing the values written to the device.
815#[derive(Debug, Clone, Copy, PartialEq, Eq)]
816pub struct StreamParams {
817    /// Selected GVSP packet size (bytes).
818    pub packet_size: u32,
819    /// Packet delay expressed in GVSP clock ticks (80 ns units).
820    pub packet_delay: u32,
821    /// Link MTU used to derive the packet size.
822    pub mtu: u32,
823    /// Host IPv4 address configured on the device.
824    pub host: Ipv4Addr,
825    /// Host port configured on the device.
826    pub port: u16,
827}
828
829impl GigeDevice {
830    /// Connect to a device GVCP endpoint.
831    ///
832    /// The connection is ready for register read/write but does not claim
833    /// control privilege. Call [`Self::claim_control`] before configuring streaming
834    /// or starting acquisition.
835    pub async fn open(addr: SocketAddr) -> Result<Self, GigeError> {
836        let local_ip = match addr.ip() {
837            IpAddr::V4(_) => IpAddr::V4(Ipv4Addr::UNSPECIFIED),
838            IpAddr::V6(_) => {
839                return Err(GigeError::Protocol("IPv6 GVCP is not supported".into()));
840            }
841        };
842        let socket = UdpSocket::bind(SocketAddr::new(local_ip, 0)).await?;
843        socket.connect(addr).await?;
844        Ok(Self {
845            socket,
846            remote: addr,
847            request_id: 1,
848            rng: Rng::new(),
849        })
850    }
851
852    /// Claim control channel privilege (CCP).
853    ///
854    /// Required by the GigE Vision specification before the device accepts
855    /// stream configuration or acquisition commands.
856    pub async fn claim_control(&mut self) -> Result<(), GigeError> {
857        self.write_register(
858            consts::CONTROL_CHANNEL_PRIVILEGE as u32,
859            consts::CCP_CONTROL,
860        )
861        .await?;
862        debug!(addr = %self.remote, "claimed control channel privilege");
863        Ok(())
864    }
865
866    /// Release control channel privilege.
867    pub async fn release_control(&mut self) -> Result<(), GigeError> {
868        self.write_register(consts::CONTROL_CHANNEL_PRIVILEGE as u32, 0)
869            .await
870    }
871
872    /// Read `GevHeartbeatTimeout` (milliseconds).
873    ///
874    /// This is how long the device will keep control privilege granted with no
875    /// GVCP command from the controlling application.
876    pub async fn heartbeat_timeout_ms(&mut self) -> Result<u32, GigeError> {
877        self.read_register(consts::HEARTBEAT_TIMEOUT as u32).await
878    }
879
880    /// Refresh the device's heartbeat timer, and report whether this
881    /// application still holds control privilege.
882    ///
883    /// Any GVCP command resets the timer, so this reads CCP rather than writing
884    /// it: a read is idempotent, and its value doubles as the answer to "does
885    /// the device still consider us the controller?". A device that revoked
886    /// privilege — because an earlier heartbeat was lost, or because another
887    /// application took control — is reported as `Ok(false)` here instead of
888    /// failing the next configuration write with `ACCESS_DENIED`.
889    ///
890    /// Losing privilege is not a transport failure, so it is not an `Err`: the
891    /// register read succeeded and the answer is simply "no". `Err` means the
892    /// command itself did not complete.
893    pub async fn ping_control_channel(&mut self) -> Result<bool, GigeError> {
894        let privilege = self
895            .read_register(consts::CONTROL_CHANNEL_PRIVILEGE as u32)
896            .await?;
897        Ok(privilege & consts::CCP_CONTROLLER_BITS != 0)
898    }
899
900    /// Return the remote GVCP socket address associated with this device.
901    pub fn remote_addr(&self) -> SocketAddr {
902        self.remote
903    }
904
905    fn next_request_id(&mut self) -> u16 {
906        let id = self.request_id;
907        self.request_id = self.request_id.wrapping_add(1);
908        if self.request_id == 0 {
909            self.request_id = 1;
910        }
911        id
912    }
913
914    async fn transact_with_retry(
915        &mut self,
916        opcode: OpCode,
917        payload: BytesMut,
918    ) -> Result<GenCpAck, GigeError> {
919        // Retries resend the same transaction. A device may finish a slow,
920        // non-idempotent command after our first receive deadline; assigning a
921        // new ID would turn its delayed acknowledgement into a mismatch and
922        // could execute the command again.
923        let request_id = self.next_request_id();
924        let payload_bytes = payload.freeze();
925        let header = GvcpRequestHeader {
926            flags: CommandFlags::ACK_REQUIRED,
927            command: opcode.command_code(),
928            length: payload_bytes.len() as u16,
929            request_id,
930        };
931        let encoded = header.encode(&payload_bytes);
932        let mut attempt = 0usize;
933        'retry: loop {
934            attempt += 1;
935            trace!(request_id, opcode = ?opcode, bytes = encoded.len(), attempt, "sending GVCP command");
936            if let Err(err) = self.socket.send(&encoded).await {
937                if attempt >= consts::MAX_RETRIES {
938                    return Err(err.into());
939                }
940                warn!(request_id, ?opcode, attempt, "send failed, retrying");
941                self.backoff(attempt).await;
942                continue 'retry;
943            }
944
945            let mut buf = vec![
946                0u8;
947                viva_gencp::HEADER_SIZE
948                    + consts::GENCP_MAX_BLOCK
949                    + consts::GENCP_WRITE_OVERHEAD
950            ];
951            let mut mismatched_acks = 0usize;
952            loop {
953                match self.recv_absorbing_pending(&mut buf, request_id).await {
954                    AckRecv::Received(len) => {
955                        trace!(request_id, bytes = len, attempt, "received GenCP ack");
956                        let ack = decode_ack(&buf[..len])?;
957                        if ack.header.request_id != request_id {
958                            debug!(
959                                request_id,
960                                got = ack.header.request_id,
961                                attempt,
962                                "acknowledgement id mismatch"
963                            );
964                            // A delayed acknowledgement from an earlier command is
965                            // normal on devices that process file operations
966                            // asynchronously. Keep waiting for this request, but
967                            // bound the number of unrelated datagrams before
968                            // restarting the same transaction.
969                            mismatched_acks += 1;
970                            if mismatched_acks < consts::MAX_RETRIES {
971                                continue;
972                            }
973                            if attempt >= consts::MAX_RETRIES {
974                                return Err(GigeError::Protocol(
975                                    "acknowledgement id mismatch".into(),
976                                ));
977                            }
978                            self.backoff(attempt).await;
979                            continue 'retry;
980                        }
981                        if ack.header.opcode != opcode {
982                            return Err(GigeError::Protocol(
983                                "unexpected opcode in acknowledgement".into(),
984                            ));
985                        }
986                        match ack.header.status {
987                            StatusCode::Success => return Ok(ack),
988                            // Only `BUSY` (0x8007) is congestion. This used to
989                            // match `DeviceBusy`, which was mapped to 0x8004 —
990                            // `WRITE_PROTECT` — so the retry loop burned its
991                            // budget on a read-only register that could never
992                            // accept the write, and gave up immediately on the
993                            // one status retrying is for.
994                            status if status.is_retryable() && attempt < consts::MAX_RETRIES => {
995                                warn!(request_id, attempt, %status, "device busy, retrying");
996                                self.backoff(attempt).await;
997                                continue 'retry;
998                            }
999                            other => return Err(GigeError::Status(other)),
1000                        }
1001                    }
1002                    AckRecv::Io(err) => {
1003                        if attempt >= consts::MAX_RETRIES {
1004                            return Err(err.into());
1005                        }
1006                        warn!(request_id, ?opcode, attempt, "receive error, retrying");
1007                        self.backoff(attempt).await;
1008                        continue 'retry;
1009                    }
1010                    AckRecv::TimedOut => {
1011                        if attempt >= consts::MAX_RETRIES {
1012                            return Err(GigeError::Timeout);
1013                        }
1014                        warn!(request_id, ?opcode, attempt, "command timeout, retrying");
1015                        self.backoff(attempt).await;
1016                        continue 'retry;
1017                    }
1018                }
1019            }
1020        }
1021    }
1022
1023    /// Receive one acknowledgement, granting the device the extra time it asks
1024    /// for via PENDING_ACK.
1025    ///
1026    /// A PENDING_ACK is not a failure and must not be retried: the command is
1027    /// still executing on the device, so resending it risks running it twice —
1028    /// which for a `WriteMem` to flash is exactly the operation you least want
1029    /// duplicated. The GigE Vision specification instead has the controller
1030    /// extend its own deadline by the time the device requests and keep
1031    /// waiting on the same request id.
1032    async fn recv_absorbing_pending(&mut self, buf: &mut [u8], request_id: u16) -> AckRecv {
1033        let mut wait = consts::CONTROL_TIMEOUT;
1034        let mut pending_seen = 0usize;
1035        loop {
1036            match time::timeout(wait, self.socket.recv(buf)).await {
1037                Ok(Ok(len)) => {
1038                    let Some((pending_id, requested)) = parse_pending_ack(&buf[..len]) else {
1039                        return AckRecv::Received(len);
1040                    };
1041                    if pending_id != request_id {
1042                        debug!(
1043                            request_id,
1044                            got = pending_id,
1045                            "ignoring PENDING_ACK for another request"
1046                        );
1047                        continue;
1048                    }
1049                    pending_seen += 1;
1050                    if pending_seen > consts::MAX_PENDING_ACKS {
1051                        warn!(
1052                            request_id,
1053                            pending_seen, "device kept requesting more time, giving up"
1054                        );
1055                        return AckRecv::TimedOut;
1056                    }
1057                    wait = requested.clamp(consts::CONTROL_TIMEOUT, consts::MAX_PENDING_ACK_WAIT);
1058                    debug!(
1059                        request_id,
1060                        requested_ms = requested.as_millis() as u64,
1061                        waiting_ms = wait.as_millis() as u64,
1062                        pending_seen,
1063                        "device requested more time (PENDING_ACK)"
1064                    );
1065                }
1066                Ok(Err(err)) => return AckRecv::Io(err),
1067                Err(_) => return AckRecv::TimedOut,
1068            }
1069        }
1070    }
1071
1072    async fn backoff(&mut self, attempt: usize) {
1073        let multiplier = 1u32 << (attempt.saturating_sub(1)).min(3);
1074        let base_ms = consts::RETRY_BASE_DELAY.as_millis() as u64;
1075        let base = Duration::from_millis(base_ms.saturating_mul(multiplier as u64).max(base_ms));
1076        let jitter_ms = self.rng.u64(..=consts::RETRY_JITTER.as_millis() as u64);
1077        let jitter = Duration::from_millis(jitter_ms);
1078        let delay = base + jitter;
1079        debug!(attempt, delay = ?delay, "gvcp retry backoff");
1080        time::sleep(delay).await;
1081    }
1082
1083    /// Read a single 32-bit bootstrap or device register.
1084    ///
1085    /// Uses GVCP READREG format: 4-byte register address.
1086    /// The acknowledgement carries the 4-byte register value.
1087    pub async fn read_register(&mut self, addr: u32) -> Result<u32, GigeError> {
1088        let mut payload = BytesMut::with_capacity(4);
1089        payload.put_u32(addr);
1090        let ack = self
1091            .transact_with_retry(OpCode::ReadRegister, payload)
1092            .await?;
1093        if ack.payload.len() != 4 {
1094            return Err(GigeError::Protocol(format!(
1095                "expected 4-byte register ack but device returned {} bytes",
1096                ack.payload.len()
1097            )));
1098        }
1099        let mut cursor = &ack.payload[..];
1100        Ok(cursor.get_u32())
1101    }
1102
1103    /// Write a single 32-bit bootstrap or device register.
1104    ///
1105    /// Uses GVCP WRITEREG format: 4-byte register address + 4-byte value.
1106    /// The acknowledgement carries a 4-byte data index placeholder.
1107    pub async fn write_register(&mut self, addr: u32, value: u32) -> Result<(), GigeError> {
1108        let mut payload = BytesMut::with_capacity(8);
1109        payload.put_u32(addr);
1110        payload.put_u32(value);
1111        let ack = self
1112            .transact_with_retry(OpCode::WriteRegister, payload)
1113            .await?;
1114        if ack.payload.len() != 4 {
1115            return Err(GigeError::Protocol(format!(
1116                "expected 4-byte register write ack but device returned {} bytes",
1117                ack.payload.len()
1118            )));
1119        }
1120        Ok(())
1121    }
1122
1123    /// Read a block of memory from the remote device with chunking and retries.
1124    ///
1125    /// Uses GVCP READMEM format: 4-byte address + 2-byte reserved + 2-byte count.
1126    /// The acknowledgement carries: 4-byte address echo + data bytes.
1127    pub async fn read_mem(&mut self, addr: u64, len: usize) -> Result<Vec<u8>, GigeError> {
1128        let mut remaining = len;
1129        let mut offset = 0usize;
1130        let mut data = Vec::with_capacity(len);
1131        while remaining > 0 {
1132            let chunk = remaining.min(consts::GENCP_MAX_BLOCK);
1133            // GVCP requires the READMEM byte count to be a multiple of 4.
1134            // Strict cameras (e.g. Hikrobot) reject unaligned counts with
1135            // InvalidParameter, so request the aligned size and drop the
1136            // padding bytes below. Device memory regions are 4-byte aligned,
1137            // so reading past the end of e.g. the XML blob is safe.
1138            let request = chunk.next_multiple_of(4);
1139            let mut payload = BytesMut::with_capacity(8);
1140            payload.put_u32((addr + offset as u64) as u32);
1141            payload.put_u16(0); // reserved
1142            payload.put_u16(request as u16);
1143            let ack = self.transact_with_retry(OpCode::ReadMem, payload).await?;
1144            // GVCP READMEM_ACK: 4-byte address prefix + data.
1145            let ack_data = if ack.payload.len() >= 4 + request {
1146                &ack.payload[4..4 + request]
1147            } else if ack.payload.len() == request {
1148                // Some devices omit the address echo.
1149                &ack.payload[..request]
1150            } else {
1151                return Err(GigeError::Protocol(format!(
1152                    "expected {} bytes but device returned {}",
1153                    request,
1154                    ack.payload.len()
1155                )));
1156            };
1157            data.extend_from_slice(&ack_data[..chunk]);
1158            remaining -= chunk;
1159            offset += chunk;
1160        }
1161        Ok(data)
1162    }
1163
1164    /// Write a block of memory to the remote device with chunking and retries.
1165    ///
1166    /// Uses GVCP WRITEMEM format: 4-byte address + data bytes.
1167    /// The acknowledgement carries: 4-byte reserved (index).
1168    pub async fn write_mem(&mut self, addr: u64, data: &[u8]) -> Result<(), GigeError> {
1169        /// GVCP WRITEMEM overhead: 4-byte address prefix.
1170        const GVCP_WRITE_OVERHEAD: usize = 4;
1171
1172        let mut offset = 0usize;
1173        while offset < data.len() {
1174            let chunk = (data.len() - offset).min(consts::GENCP_MAX_BLOCK - GVCP_WRITE_OVERHEAD);
1175            if chunk == 0 {
1176                return Err(GigeError::Protocol("write chunk size is zero".into()));
1177            }
1178            let mut payload = BytesMut::with_capacity(GVCP_WRITE_OVERHEAD + chunk);
1179            payload.put_u32((addr + offset as u64) as u32);
1180            payload.extend_from_slice(&data[offset..offset + chunk]);
1181            let ack = self.transact_with_retry(OpCode::WriteMem, payload).await?;
1182            // GVCP WRITEMEM_ACK: 4-byte reserved payload.
1183            if ack.payload.len() > 4 {
1184                return Err(GigeError::Protocol(
1185                    "write acknowledgement carried unexpected payload".into(),
1186                ));
1187            }
1188            offset += chunk;
1189        }
1190        Ok(())
1191    }
1192
1193    /// Configure the message channel destination address/port.
1194    pub async fn set_message_destination(
1195        &mut self,
1196        ip: Ipv4Addr,
1197        port: u16,
1198    ) -> Result<(), GigeError> {
1199        info!(%ip, port, "configuring message channel destination");
1200        self.write_mem(consts::MESSAGE_DESTINATION_ADDRESS, &ip.octets())
1201            .await?;
1202        // GevMCP is a 32-bit register with the port in the low half. Writing
1203        // only the two port bytes lands them in the *high* half.
1204        self.write_mem(
1205            consts::MESSAGE_DESTINATION_PORT,
1206            &u32::from(port).to_be_bytes(),
1207        )
1208        .await?;
1209        Ok(())
1210    }
1211
1212    fn stream_reg(channel: u32, offset: u64) -> u64 {
1213        consts::STREAM_CHANNEL_BASE + channel as u64 * consts::STREAM_CHANNEL_STRIDE + offset
1214    }
1215
1216    /// Configure the GVSP host destination for the provided channel.
1217    pub async fn set_stream_destination(
1218        &mut self,
1219        channel: u32,
1220        ip: Ipv4Addr,
1221        port: u16,
1222    ) -> Result<(), GigeError> {
1223        info!(channel, %ip, port, "configuring stream destination");
1224        let addr = Self::stream_reg(channel, consts::STREAM_DESTINATION_ADDRESS);
1225        self.write_mem(addr, &ip.octets()).await?;
1226        let addr = Self::stream_reg(channel, consts::STREAM_DESTINATION_PORT);
1227        self.write_mem(addr, &(port as u32).to_be_bytes()).await?;
1228        Ok(())
1229    }
1230
1231    /// Configure the packet size for the stream channel.
1232    ///
1233    /// Only bits 0-15 of `GevSCPSPacketSize` hold the size; the high bits are
1234    /// reserved for the fire-test-packet and do-not-fragment flags. A larger
1235    /// value is refused rather than truncated, because truncation is silent and
1236    /// arrives as a stream that never completes a frame — writing 70 000 would
1237    /// configure 4 464.
1238    pub async fn set_stream_packet_size(
1239        &mut self,
1240        channel: u32,
1241        packet_size: u32,
1242    ) -> Result<(), GigeError> {
1243        if packet_size > STREAM_PACKET_SIZE_MASK {
1244            return Err(GigeError::Protocol(format!(
1245                "GVSP packet size {packet_size} does not fit GevSCPSPacketSize: the size field is \
1246                 bits 0-15, so the device would receive {}",
1247                packet_size & STREAM_PACKET_SIZE_MASK
1248            )));
1249        }
1250        info!(channel, packet_size, "configuring stream packet size");
1251        let addr = Self::stream_reg(channel, consts::STREAM_PACKET_SIZE);
1252        self.write_mem(addr, &packet_size.to_be_bytes()).await
1253    }
1254
1255    /// Ask the device to emit one GVSP test packet of `packet_size` bytes.
1256    ///
1257    /// Writes `GevSCPSPacketSize` with bit 31 (fire test packet) and bit 30
1258    /// (do not fragment) set. The size lands in the register as usual; the
1259    /// flags do not, and a device that echoed them back would report a packet
1260    /// size of over a billion, so [`GigeDevice::get_stream_packet_size`] masks.
1261    ///
1262    /// This does not wait for the packet — it arrives on the stream channel,
1263    /// which this type does not own. The caller listens.
1264    pub async fn request_test_packet(
1265        &mut self,
1266        channel: u32,
1267        packet_size: u32,
1268    ) -> Result<(), GigeError> {
1269        if packet_size > STREAM_PACKET_SIZE_MASK {
1270            return Err(GigeError::Protocol(format!(
1271                "GVSP packet size {packet_size} does not fit GevSCPSPacketSize"
1272            )));
1273        }
1274        let addr = Self::stream_reg(channel, consts::STREAM_PACKET_SIZE);
1275        let word = packet_size | SCPS_FIRE_TEST_PACKET | SCPS_DO_NOT_FRAGMENT;
1276        debug!(channel, packet_size, "requesting GVSP test packet");
1277        self.write_mem(addr, &word.to_be_bytes()).await
1278    }
1279
1280    /// Read `GevSCPSPacketSize` back and return the size the device holds.
1281    ///
1282    /// A device is free to clamp a requested packet size to what it supports,
1283    /// and the request succeeds when it does — nothing on the wire distinguishes
1284    /// "accepted" from "accepted and reduced". Until the receive path follows
1285    /// the *effective* value it reassembles at the wrong pitch and no frame ever
1286    /// completes ([#112](https://github.com/VitalyVorobyev/viva-genicam/issues/112),
1287    /// backlog SR-02).
1288    ///
1289    /// Deliberately a GVCP READREG rather than a GenApi node read: the write
1290    /// side bypasses the `NodeMap`, so a cached `GevSCPSPacketSize` node can
1291    /// report the pre-write value and turn this check into a second bug.
1292    pub async fn get_stream_packet_size(&mut self, channel: u32) -> Result<u32, GigeError> {
1293        let addr = Self::stream_reg(channel, consts::STREAM_PACKET_SIZE) as u32;
1294        let raw = self.read_register(addr).await?;
1295        Ok(raw & STREAM_PACKET_SIZE_MASK)
1296    }
1297
1298    /// Configure the packet delay (`GevSCPD`).
1299    pub async fn set_stream_packet_delay(
1300        &mut self,
1301        channel: u32,
1302        packet_delay: u32,
1303    ) -> Result<(), GigeError> {
1304        debug!(channel, packet_delay, "configuring stream packet delay");
1305        let addr = Self::stream_reg(channel, consts::STREAM_PACKET_DELAY);
1306        self.write_mem(addr, &packet_delay.to_be_bytes()).await
1307    }
1308
1309    /// Negotiate GVSP parameters with the device given the host interface.
1310    pub async fn negotiate_stream(
1311        &mut self,
1312        channel: u32,
1313        iface: &Iface,
1314        port: u16,
1315        target_mtu: Option<u32>,
1316    ) -> Result<StreamParams, GigeError> {
1317        let host_ip = iface
1318            .ipv4()
1319            .ok_or_else(|| GigeError::Protocol("interface lacks IPv4 address".into()))?;
1320        let iface_mtu = nic::mtu(iface)?;
1321        let mtu = target_mtu.map_or(iface_mtu, |limit| limit.min(iface_mtu));
1322        let packet_size = nic::best_packet_size(mtu);
1323        let packet_delay = if mtu <= 1500 {
1324            // When jumbo frames are unavailable we space out packets by 2 µs to
1325            // prevent excessive buffering pressure on receivers. GVSP expresses
1326            // `GevSCPD` in units of 80 ns.
1327            const DELAY_NS: u32 = 2_000; // 2 µs.
1328            DELAY_NS / 80
1329        } else {
1330            0
1331        };
1332
1333        self.set_stream_destination(channel, host_ip, port).await?;
1334        self.set_stream_packet_size(channel, packet_size).await?;
1335        self.set_stream_packet_delay(channel, packet_delay).await?;
1336
1337        Ok(StreamParams {
1338            packet_size,
1339            packet_delay,
1340            mtu,
1341            host: host_ip,
1342            port,
1343        })
1344    }
1345    /// Read the persistent IP configuration from the device.
1346    ///
1347    /// Returns `(ip, subnet, gateway)`.
1348    pub async fn read_persistent_ip(
1349        &mut self,
1350    ) -> Result<(Ipv4Addr, Ipv4Addr, Ipv4Addr), GigeError> {
1351        let ip = Ipv4Addr::from(
1352            self.read_register(consts::PERSISTENT_IP_ADDRESS as u32)
1353                .await?,
1354        );
1355        let subnet = Ipv4Addr::from(
1356            self.read_register(consts::PERSISTENT_SUBNET_MASK as u32)
1357                .await?,
1358        );
1359        let gateway = Ipv4Addr::from(
1360            self.read_register(consts::PERSISTENT_DEFAULT_GATEWAY as u32)
1361                .await?,
1362        );
1363        Ok((ip, subnet, gateway))
1364    }
1365
1366    /// Write the persistent IP configuration to the device.
1367    pub async fn write_persistent_ip(
1368        &mut self,
1369        ip: Ipv4Addr,
1370        subnet: Ipv4Addr,
1371        gateway: Ipv4Addr,
1372    ) -> Result<(), GigeError> {
1373        self.write_register(consts::PERSISTENT_IP_ADDRESS as u32, u32::from(ip))
1374            .await?;
1375        self.write_register(consts::PERSISTENT_SUBNET_MASK as u32, u32::from(subnet))
1376            .await?;
1377        self.write_register(
1378            consts::PERSISTENT_DEFAULT_GATEWAY as u32,
1379            u32::from(gateway),
1380        )
1381        .await?;
1382        info!(%ip, %subnet, %gateway, "wrote persistent IP configuration");
1383        Ok(())
1384    }
1385
1386    /// Enable persistent IP mode in the device configuration flags.
1387    ///
1388    /// Sets bit 1 (persistent IP) in the `CurrentIPConfiguration` register.
1389    pub async fn enable_persistent_ip(&mut self) -> Result<(), GigeError> {
1390        let current = self.read_register(consts::CURRENT_IP_CONFIG as u32).await?;
1391        let updated = current | 0x02; // bit 1 = persistent IP
1392        self.write_register(consts::CURRENT_IP_CONFIG as u32, updated)
1393            .await?;
1394        info!(config = format!("0x{updated:08x}"), "enabled persistent IP");
1395        Ok(())
1396    }
1397
1398    /// Request resend of a packet range for the provided block identifier.
1399    pub async fn request_resend(
1400        &mut self,
1401        block_id: u16,
1402        first_packet: u16,
1403        last_packet: u16,
1404    ) -> Result<(), GigeError> {
1405        let mut payload = BytesMut::with_capacity(8);
1406        payload.put_u16(block_id);
1407        payload.put_u16(0); // Reserved as per spec.
1408        payload.put_u16(first_packet);
1409        payload.put_u16(last_packet);
1410
1411        let request_id = self.next_request_id();
1412        let header = GvcpRequestHeader {
1413            flags: CommandFlags::ACK_REQUIRED,
1414            command: consts::PACKET_RESEND_COMMAND,
1415            length: payload.len() as u16,
1416            request_id,
1417        };
1418        let packet = header.encode(&payload);
1419        trace!(
1420            block_id,
1421            first_packet, last_packet, request_id, "sending packet resend request"
1422        );
1423        self.socket.send(&packet).await?;
1424        let mut buf = [0u8; viva_gencp::HEADER_SIZE];
1425        match time::timeout(consts::CONTROL_TIMEOUT, self.socket.recv(&mut buf)).await {
1426            Ok(Ok(len)) => {
1427                if len != viva_gencp::HEADER_SIZE {
1428                    return Err(GigeError::Protocol("resend ack length mismatch".into()));
1429                }
1430                let mut cursor = &buf[..];
1431                let status = StatusCode::from_raw(cursor.get_u16());
1432                let command = cursor.get_u16();
1433                let length = cursor.get_u16();
1434                let ack_request_id = cursor.get_u16();
1435                if command == consts::PENDING_ACK {
1436                    // Legal, but not worth waiting for: by the time the device
1437                    // finished, the frame this resend belongs to would already
1438                    // have been completed or dropped. Report it accurately
1439                    // instead of as an unexpected opcode.
1440                    return Err(GigeError::Protocol(
1441                        "device requested more time for a packet resend".into(),
1442                    ));
1443                }
1444                if command != consts::PACKET_RESEND_ACK {
1445                    return Err(GigeError::Protocol("unexpected resend ack opcode".into()));
1446                }
1447                if length != 0 {
1448                    return Err(GigeError::Protocol("resend ack carried payload".into()));
1449                }
1450                if ack_request_id != request_id {
1451                    return Err(GigeError::Protocol("resend ack request id mismatch".into()));
1452                }
1453                if status != StatusCode::Success {
1454                    return Err(GigeError::Status(status));
1455                }
1456                Ok(())
1457            }
1458            Ok(Err(err)) => Err(err.into()),
1459            Err(_) => Err(GigeError::Timeout),
1460        }
1461    }
1462}
1463
1464#[cfg(test)]
1465mod tests {
1466    use super::*;
1467
1468    /// A Discovery ACK payload written out from the specification's field
1469    /// table, with a distinct recognisable value in every field.
1470    ///
1471    /// Built here rather than by round-tripping our own encoder: the point of
1472    /// this fixture is to disagree with the parser if the parser is wrong. See
1473    /// [ADR-0019]. The offsets are corroborated by Wireshark's
1474    /// `dissect_discovery_ack()`, which reads the MAC from `offset + 10` and
1475    /// the IP, manufacturer and model from 36, 72 and 104.
1476    ///
1477    /// [ADR-0019]: https://github.com/VitalyVorobyev/viva-genicam/blob/main/docs/adrs/adr0019-transport-conformance-and-spec-derived-fakes.md
1478    fn golden_discovery_payload() -> Vec<u8> {
1479        let mut p = vec![0u8; 248];
1480        p[0..2].copy_from_slice(&2u16.to_be_bytes()); // spec major
1481        p[2..4].copy_from_slice(&1u16.to_be_bytes()); // spec minor
1482        p[4..8].copy_from_slice(&0u32.to_be_bytes()); // device mode
1483        // 8..10 is the padding half of the MAC-high register.
1484        p[10..16].copy_from_slice(&[0x00, 0x0C, 0xDF, 0x06, 0x5B, 0x2F]); // MAC
1485        p[16..20].copy_from_slice(&7u32.to_be_bytes()); // supported IP config
1486        p[20..24].copy_from_slice(&5u32.to_be_bytes()); // current IP config
1487        p[36..40].copy_from_slice(&[169, 254, 78, 62]); // current IP
1488        p[52..56].copy_from_slice(&[255, 255, 0, 0]); // subnet
1489        p[68..72].copy_from_slice(&[0, 0, 0, 0]); // gateway
1490        let put =
1491            |p: &mut [u8], at: usize, s: &str| p[at..at + s.len()].copy_from_slice(s.as_bytes());
1492        put(&mut p, 72, "JAI Corporation"); // manufacturer
1493        put(&mut p, 104, "FS-3200T-10GE-NNC"); // model
1494        put(&mut p, 136, "1.2.3"); // device version
1495        put(&mut p, 168, "mfr-specific"); // manufacturer info
1496        put(&mut p, 216, "SN-12345"); // serial
1497        put(&mut p, 232, "left-camera"); // user-defined name
1498        p
1499    }
1500
1501    #[test]
1502    fn discovery_payload_matches_spec_offsets() {
1503        let info = parse_discovery_payload(&golden_discovery_payload()).expect("parse");
1504
1505        // The MAC begins at offset 10. Reading it at 12 — as we did before
1506        // #57 — yields DF:06:5B:2F:00:07, silently folding two bytes of
1507        // SupportedIPConfiguration into the address.
1508        assert_eq!(info.mac, [0x00, 0x0C, 0xDF, 0x06, 0x5B, 0x2F]);
1509        assert_eq!(info.mac_string(), "00:0C:DF:06:5B:2F");
1510        assert_eq!(info.ip, Ipv4Addr::new(169, 254, 78, 62));
1511        assert_eq!(info.manufacturer.as_deref(), Some("JAI Corporation"));
1512        assert_eq!(info.model.as_deref(), Some("FS-3200T-10GE-NNC"));
1513        assert_eq!(info.version.as_deref(), Some("1.2.3"));
1514        assert_eq!(info.serial.as_deref(), Some("SN-12345"));
1515        assert_eq!(info.user_name.as_deref(), Some("left-camera"));
1516    }
1517
1518    #[test]
1519    fn discovery_payload_tolerates_truncation() {
1520        // A device that stops after the IP field still yields an addressable
1521        // camera rather than failing discovery on that interface.
1522        let short = golden_discovery_payload()[..40].to_vec();
1523        let info = parse_discovery_payload(&short).expect("short payload should parse");
1524        assert_eq!(info.ip, Ipv4Addr::new(169, 254, 78, 62));
1525        assert_eq!(info.mac, [0x00, 0x0C, 0xDF, 0x06, 0x5B, 0x2F]);
1526        assert_eq!(info.manufacturer, None);
1527        assert_eq!(info.serial, None);
1528
1529        // Below the IP field there is nothing usable.
1530        assert!(parse_discovery_payload(&[0u8; 12]).is_err());
1531    }
1532
1533    #[test]
1534    fn discovery_ack_ignores_foreign_traffic() {
1535        let payload = golden_discovery_payload();
1536        let ack = |status: u16, command: u16, request_id: u16| {
1537            let mut buf = Vec::new();
1538            buf.extend_from_slice(&status.to_be_bytes());
1539            buf.extend_from_slice(&command.to_be_bytes());
1540            buf.extend_from_slice(&(payload.len() as u16).to_be_bytes());
1541            buf.extend_from_slice(&request_id.to_be_bytes());
1542            buf.extend_from_slice(&payload);
1543            buf
1544        };
1545
1546        // The real thing.
1547        assert!(parse_discovery_ack(&ack(0, consts::DISCOVERY_ACK, 0x0100), 0x0100).is_some());
1548        // Someone else's request id, a READREG ack that landed on our socket,
1549        // an error status, and a runt datagram must all be ignored rather than
1550        // failing discovery for every camera on the interface (#57).
1551        assert!(parse_discovery_ack(&ack(0, consts::DISCOVERY_ACK, 0x0999), 0x0100).is_none());
1552        assert!(parse_discovery_ack(&ack(0, 0x0081, 0x0100), 0x0100).is_none());
1553        assert!(parse_discovery_ack(&ack(0x8002, consts::DISCOVERY_ACK, 0x0100), 0x0100).is_none());
1554        assert!(parse_discovery_ack(&[0u8; 4], 0x0100).is_none());
1555    }
1556
1557    #[test]
1558    fn directed_broadcast_uses_netmask_for_link_local_address() {
1559        // Linux reports no `brd` field for an address added as
1560        // `169.254.1.10/16`; `if-addrs` then exposes the local address as the
1561        // broadcast destination. Deriving it from the netmask must still
1562        // reach every APIPA peer on the link.
1563        assert_eq!(
1564            directed_broadcast(
1565                Ipv4Addr::new(169, 254, 1, 10),
1566                Ipv4Addr::new(255, 255, 0, 0),
1567            ),
1568            Ipv4Addr::new(169, 254, 255, 255)
1569        );
1570    }
1571
1572    /// A PENDING_ACK written out from the specification's field table:
1573    /// the 8-byte acknowledgement header, two reserved bytes, then a 16-bit
1574    /// `time_to_completion` in milliseconds.
1575    fn golden_pending_ack(request_id: u16, millis: u16) -> Vec<u8> {
1576        let mut buf = Vec::new();
1577        buf.extend_from_slice(&0u16.to_be_bytes()); // status: success
1578        buf.extend_from_slice(&consts::PENDING_ACK.to_be_bytes()); // 0x0089
1579        buf.extend_from_slice(&4u16.to_be_bytes()); // payload length
1580        buf.extend_from_slice(&request_id.to_be_bytes());
1581        buf.extend_from_slice(&0u16.to_be_bytes()); // reserved
1582        buf.extend_from_slice(&millis.to_be_bytes()); // time to completion
1583        buf
1584    }
1585
1586    #[test]
1587    fn pending_ack_matches_spec_offsets() {
1588        let (id, wait) = parse_pending_ack(&golden_pending_ack(0x1234, 750)).expect("pending ack");
1589        assert_eq!(id, 0x1234);
1590        assert_eq!(wait, Duration::from_millis(750));
1591
1592        // The time is the u16 at payload offset 2, not a u32 over the whole
1593        // payload. The two readings agree only while the reserved field is
1594        // zero; a device that leaves junk there would ask aravis for
1595        // 0xDEAD_02EE ms — around 41 days — where we read 750.
1596        let mut junk = golden_pending_ack(0x1234, 750);
1597        junk[8..10].copy_from_slice(&0xDEADu16.to_be_bytes());
1598        assert_eq!(
1599            parse_pending_ack(&junk).expect("pending ack").1.as_millis(),
1600            750
1601        );
1602    }
1603
1604    #[test]
1605    fn pending_ack_is_distinguished_from_real_acks() {
1606        // A genuine READREG ack must not be mistaken for a request for time,
1607        // or every register read would hang until the retry budget ran out.
1608        let mut readreg = Vec::new();
1609        readreg.extend_from_slice(&0u16.to_be_bytes());
1610        readreg.extend_from_slice(&0x0081u16.to_be_bytes());
1611        readreg.extend_from_slice(&4u16.to_be_bytes());
1612        readreg.extend_from_slice(&0x1234u16.to_be_bytes());
1613        readreg.extend_from_slice(&0u32.to_be_bytes());
1614        assert!(parse_pending_ack(&readreg).is_none());
1615        assert!(parse_pending_ack(&[0u8; 4]).is_none());
1616
1617        // A device that truncates the payload is still asking for time.
1618        let truncated = &golden_pending_ack(0x1234, 750)[..viva_gencp::HEADER_SIZE];
1619        let (id, wait) = parse_pending_ack(truncated).expect("truncated pending ack");
1620        assert_eq!(id, 0x1234);
1621        assert_eq!(wait, consts::CONTROL_TIMEOUT);
1622    }
1623
1624    /// A minimal GVCP device that answers `pending` PENDING_ACKs before the
1625    /// real acknowledgement, counting how many commands it was actually sent.
1626    ///
1627    /// The count is the point: a controller that treats PENDING_ACK as a
1628    /// failure and retries would execute the command more than once.
1629    async fn pending_ack_device(
1630        pending: usize,
1631        wait_ms: u16,
1632        stale_ack: bool,
1633    ) -> (SocketAddr, tokio::task::JoinHandle<usize>) {
1634        let sock = UdpSocket::bind("127.0.0.1:0").await.expect("bind");
1635        let addr = sock.local_addr().expect("addr");
1636        let handle = tokio::spawn(async move {
1637            let mut buf = [0u8; 2048];
1638            let mut commands = 0usize;
1639            let (len, peer) = sock.recv_from(&mut buf).await.expect("recv");
1640            commands += 1;
1641            let request_id = u16::from_be_bytes([buf[6], buf[7]]);
1642            debug_assert!(len >= viva_gencp::HEADER_SIZE);
1643            for _ in 0..pending {
1644                let ack = golden_pending_ack(request_id, wait_ms);
1645                sock.send_to(&ack, peer).await.expect("send pending");
1646            }
1647            if stale_ack {
1648                let mut ack = Vec::new();
1649                ack.extend_from_slice(&0u16.to_be_bytes());
1650                ack.extend_from_slice(&0x0081u16.to_be_bytes());
1651                ack.extend_from_slice(&4u16.to_be_bytes());
1652                ack.extend_from_slice(&request_id.wrapping_add(1).to_be_bytes());
1653                ack.extend_from_slice(&0xDEADBEEFu32.to_be_bytes());
1654                sock.send_to(&ack, peer).await.expect("send stale ack");
1655            }
1656            // The real READREG ack: one 4-byte register value.
1657            let mut ack = Vec::new();
1658            ack.extend_from_slice(&0u16.to_be_bytes());
1659            ack.extend_from_slice(&0x0081u16.to_be_bytes());
1660            ack.extend_from_slice(&4u16.to_be_bytes());
1661            ack.extend_from_slice(&request_id.to_be_bytes());
1662            ack.extend_from_slice(&0xCAFEBABEu32.to_be_bytes());
1663            sock.send_to(&ack, peer).await.expect("send ack");
1664            // Drain any retry the controller wrongly sent, so the count is
1665            // observable rather than lost in the socket buffer.
1666            let drain = time::timeout(Duration::from_millis(200), sock.recv_from(&mut buf)).await;
1667            if drain.is_ok() {
1668                commands += 1;
1669            }
1670            commands
1671        });
1672        (addr, handle)
1673    }
1674
1675    #[tokio::test]
1676    async fn pending_ack_extends_the_deadline_without_resending() {
1677        let (addr, server) = pending_ack_device(1, 300, false).await;
1678        let mut device = GigeDevice::open(addr).await.expect("open");
1679        let value = device.read_register(0x0a00).await.expect("read register");
1680        assert_eq!(value, 0xCAFEBABE);
1681        assert_eq!(server.await.expect("join"), 1, "command must not be resent");
1682    }
1683
1684    #[tokio::test]
1685    async fn repeated_pending_acks_are_all_honoured() {
1686        // A flash write can take several rounds. Each one restarts the clock;
1687        // the command is still sent exactly once.
1688        let (addr, server) = pending_ack_device(3, 200, false).await;
1689        let mut device = GigeDevice::open(addr).await.expect("open");
1690        let value = device.read_register(0x0a00).await.expect("read register");
1691        assert_eq!(value, 0xCAFEBABE);
1692        assert_eq!(server.await.expect("join"), 1, "command must not be resent");
1693    }
1694
1695    #[tokio::test]
1696    async fn stale_acknowledgement_is_ignored_without_resending() {
1697        let (addr, server) = pending_ack_device(0, 0, true).await;
1698        let mut device = GigeDevice::open(addr).await.expect("open");
1699        let value = device.read_register(0x0a00).await.expect("read register");
1700        assert_eq!(value, 0xCAFEBABE);
1701        assert_eq!(server.await.expect("join"), 1, "command must not be resent");
1702    }
1703
1704    /// A device that answers `error_replies` commands with `status_raw` and
1705    /// then, if it is asked again, succeeds. Returns the number of commands it
1706    /// received, which is what distinguishes "retried" from "reported".
1707    async fn status_device(
1708        status_raw: u16,
1709        error_replies: usize,
1710    ) -> (SocketAddr, tokio::task::JoinHandle<usize>) {
1711        let sock = UdpSocket::bind("127.0.0.1:0").await.expect("bind");
1712        let addr = sock.local_addr().expect("addr");
1713        let handle = tokio::spawn(async move {
1714            let mut buf = [0u8; 2048];
1715            let mut commands = 0usize;
1716            loop {
1717                let recv =
1718                    time::timeout(Duration::from_millis(600), sock.recv_from(&mut buf)).await;
1719                let Ok(Ok((_len, peer))) = recv else { break };
1720                commands += 1;
1721                let request_id = u16::from_be_bytes([buf[6], buf[7]]);
1722                let mut ack = Vec::new();
1723                if commands <= error_replies {
1724                    ack.extend_from_slice(&status_raw.to_be_bytes());
1725                    ack.extend_from_slice(&0x0081u16.to_be_bytes());
1726                    ack.extend_from_slice(&0u16.to_be_bytes());
1727                    ack.extend_from_slice(&request_id.to_be_bytes());
1728                } else {
1729                    ack.extend_from_slice(&0u16.to_be_bytes());
1730                    ack.extend_from_slice(&0x0081u16.to_be_bytes());
1731                    ack.extend_from_slice(&4u16.to_be_bytes());
1732                    ack.extend_from_slice(&request_id.to_be_bytes());
1733                    ack.extend_from_slice(&0xCAFEBABEu32.to_be_bytes());
1734                }
1735                sock.send_to(&ack, peer).await.expect("send ack");
1736            }
1737            commands
1738        });
1739        (addr, handle)
1740    }
1741
1742    #[tokio::test]
1743    async fn busy_is_retried() {
1744        // 0x8007 BUSY is congestion: the command can succeed if asked again.
1745        let (addr, server) = status_device(0x8007, 1).await;
1746        let mut device = GigeDevice::open(addr).await.expect("open");
1747        let value = device.read_register(0x0a00).await.expect("read register");
1748        assert_eq!(value, 0xCAFEBABE);
1749        assert_eq!(
1750            server.await.expect("join"),
1751            2,
1752            "BUSY must be retried, so the device sees a second command"
1753        );
1754    }
1755
1756    #[tokio::test]
1757    async fn write_protect_is_reported_not_retried() {
1758        // 0x8004 WRITE_PROTECT is permanent. It used to be decoded as
1759        // `DeviceBusy`, so the retry loop spent its whole budget on a register
1760        // that could never accept the write.
1761        let (addr, server) = status_device(0x8004, usize::MAX).await;
1762        let mut device = GigeDevice::open(addr).await.expect("open");
1763        let err = device
1764            .read_register(0x0a00)
1765            .await
1766            .expect_err("write protect must surface");
1767        assert!(
1768            matches!(err, GigeError::Status(StatusCode::WriteProtect)),
1769            "expected WRITE_PROTECT, got {err:?}"
1770        );
1771        assert_eq!(
1772            server.await.expect("join"),
1773            1,
1774            "a permanent refusal must not be retried"
1775        );
1776    }
1777
1778    #[tokio::test]
1779    async fn access_denied_names_itself_in_the_error() {
1780        // The #45 case: the user saw `Unknown(32774)` and could tell us nothing.
1781        let (addr, server) = status_device(0x8006, usize::MAX).await;
1782        let mut device = GigeDevice::open(addr).await.expect("open");
1783        let err = device
1784            .read_register(0x0a00)
1785            .await
1786            .expect_err("access denied must surface");
1787        assert_eq!(
1788            err.to_string(),
1789            "device reported status ACCESS_DENIED (0x8006)"
1790        );
1791        assert_eq!(server.await.expect("join"), 1);
1792    }
1793
1794    #[test]
1795    fn request_header_roundtrip() {
1796        let header = GvcpRequestHeader {
1797            flags: CommandFlags::ACK_REQUIRED,
1798            command: 0x1234,
1799            length: 4,
1800            request_id: 0xBEEF,
1801        };
1802        let payload = [1u8, 2, 3, 4];
1803        let encoded = header.encode(&payload);
1804        assert_eq!(encoded.len(), viva_gencp::HEADER_SIZE + payload.len());
1805        // GVCP wire format: byte 0 = 0x42 key, byte 1 = flags byte.
1806        assert_eq!(encoded[0], GVCP_CMD_KEY);
1807        assert_eq!(encoded[1], 0x01); // ACK_REQUIRED
1808        assert_eq!(&encoded[2..4], &header.command.to_be_bytes());
1809        assert_eq!(&encoded[4..6], &header.length.to_be_bytes());
1810        assert_eq!(&encoded[6..8], &header.request_id.to_be_bytes());
1811        assert_eq!(&encoded[8..], &payload);
1812    }
1813
1814    #[test]
1815    fn forceip_payload_encoding() {
1816        let mac = [0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE];
1817        let ip = Ipv4Addr::new(192, 168, 1, 100);
1818        let subnet = Ipv4Addr::new(255, 255, 255, 0);
1819        let gateway = Ipv4Addr::new(192, 168, 1, 1);
1820        let payload = encode_forceip_payload(mac, ip, subnet, gateway);
1821        assert_eq!(payload.len(), 56);
1822        // MAC at offset 2..8
1823        assert_eq!(&payload[2..8], &mac);
1824        // IP at offset 20..24
1825        assert_eq!(&payload[20..24], &ip.octets());
1826        // Subnet at offset 36..40
1827        assert_eq!(&payload[36..40], &subnet.octets());
1828        // Gateway at offset 52..56
1829        assert_eq!(&payload[52..56], &gateway.octets());
1830        // Reserved bytes should be zero
1831        assert_eq!(&payload[0..2], &[0, 0]);
1832        assert_eq!(&payload[8..20], &[0u8; 12]);
1833        assert_eq!(&payload[24..36], &[0u8; 12]);
1834        assert_eq!(&payload[40..52], &[0u8; 12]);
1835    }
1836
1837    #[test]
1838    fn ack_header_conversion() {
1839        let ack = AckHeader {
1840            status: StatusCode::Busy,
1841            opcode: OpCode::ReadMem,
1842            length: 12,
1843            request_id: 0x44,
1844        };
1845        let converted = GvcpAckHeader::from(ack);
1846        assert_eq!(converted.status, StatusCode::Busy);
1847        assert_eq!(converted.command, OpCode::ReadMem.ack_code());
1848        assert_eq!(converted.length, 12);
1849        assert_eq!(converted.request_id, 0x44);
1850    }
1851}