Add ONVIF types and structures for device capabilities and configurations
- Introduced comprehensive data structures for ONVIF device information, capabilities, media profiles, and configurations. - Added types for device services, network settings, imaging options, audio/video configurations, and user management. - Implemented structures for handling various ONVIF features including PTZ control, event subscriptions, and analytics configurations. - Enhanced support for network protocols, security settings, and system logging.
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
# Network Interface Discovery Guide
|
||||
|
||||
This guide explains how to use the network interface selection feature for ONVIF device discovery.
|
||||
|
||||
## Overview
|
||||
|
||||
When you have multiple network interfaces on your system, you may need to specify which interface to use for sending multicast discovery messages to find your cameras. This is especially important when:
|
||||
|
||||
- You have multiple network cards (Ethernet, WiFi, Virtual Adapters)
|
||||
- Cameras are on a specific network segment
|
||||
- The auto-detected interface doesn't reach your cameras
|
||||
- You want to isolate discovery traffic to a specific network
|
||||
|
||||
## Features
|
||||
|
||||
✅ **Specify by Interface Name** - Use interface name (e.g., "eth0", "wlan0")
|
||||
✅ **Specify by IP Address** - Use any IP assigned to the interface
|
||||
✅ **List Available Interfaces** - See all interfaces with their configurations
|
||||
✅ **Backward Compatible** - Existing code continues to work unchanged
|
||||
✅ **Helpful Error Messages** - Lists available interfaces when one isn't found
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### 1. List Available Network Interfaces
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"github.com/0x524a/onvif-go/discovery"
|
||||
)
|
||||
|
||||
func main() {
|
||||
interfaces, err := discovery.ListNetworkInterfaces()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println("Available Network Interfaces:")
|
||||
for _, iface := range interfaces {
|
||||
fmt.Printf(" %s - Up: %v, Multicast: %v\n", iface.Name, iface.Up, iface.Multicast)
|
||||
for _, addr := range iface.Addresses {
|
||||
fmt.Printf(" IP: %s\n", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Output Example:**
|
||||
```
|
||||
Available Network Interfaces:
|
||||
lo - Up: true, Multicast: true
|
||||
IP: 127.0.0.1
|
||||
IP: ::1
|
||||
eth0 - Up: true, Multicast: true
|
||||
IP: 192.168.1.100
|
||||
IP: 169.254.1.1
|
||||
wlan0 - Up: true, Multicast: true
|
||||
IP: 192.168.88.50
|
||||
docker0 - Up: true, Multicast: true
|
||||
IP: 172.17.0.1
|
||||
```
|
||||
|
||||
### 2. Discover Cameras on Specific Interface (by name)
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
"github.com/0x524a/onvif-go/discovery"
|
||||
)
|
||||
|
||||
func main() {
|
||||
opts := &discovery.DiscoverOptions{
|
||||
NetworkInterface: "eth0", // Discover on Ethernet
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
devices, err := discovery.DiscoverWithOptions(ctx, 5*time.Second, opts)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d devices on eth0:\n", len(devices))
|
||||
for _, device := range devices {
|
||||
fmt.Printf(" - %s\n", device.GetDeviceEndpoint())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Discover Cameras Using IP Address
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
"github.com/0x524a/onvif-go/discovery"
|
||||
)
|
||||
|
||||
func main() {
|
||||
opts := &discovery.DiscoverOptions{
|
||||
NetworkInterface: "192.168.1.100", // Use interface with this IP
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
devices, err := discovery.DiscoverWithOptions(ctx, 5*time.Second, opts)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d devices:\n", len(devices))
|
||||
for _, device := range devices {
|
||||
fmt.Printf(" - %s\n", device.GetDeviceEndpoint())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Backward Compatible - No Changes Required
|
||||
|
||||
Existing code continues to work without modification:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
"github.com/0x524a/onvif-go/discovery"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// This still works exactly as before
|
||||
devices, err := discovery.Discover(ctx, 5*time.Second)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d devices\n", len(devices))
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### DiscoverOptions
|
||||
|
||||
```go
|
||||
type DiscoverOptions struct {
|
||||
// NetworkInterface specifies the network interface to use for multicast.
|
||||
// If empty, the system will choose the default interface.
|
||||
// Examples: "eth0", "wlan0", "192.168.1.100"
|
||||
NetworkInterface string
|
||||
}
|
||||
```
|
||||
|
||||
### Functions
|
||||
|
||||
#### `Discover(ctx context.Context, timeout time.Duration) ([]*Device, error)`
|
||||
|
||||
Discovers ONVIF devices using the default network interface (backward compatible).
|
||||
|
||||
**Parameters:**
|
||||
- `ctx`: Context for cancellation and timeout
|
||||
- `timeout`: How long to listen for responses
|
||||
|
||||
**Returns:**
|
||||
- `[]*Device`: Discovered devices
|
||||
- `error`: Any error that occurred
|
||||
|
||||
#### `DiscoverWithOptions(ctx context.Context, timeout time.Duration, opts *DiscoverOptions) ([]*Device, error)`
|
||||
|
||||
Discovers ONVIF devices with custom options including network interface selection.
|
||||
|
||||
**Parameters:**
|
||||
- `ctx`: Context for cancellation and timeout
|
||||
- `timeout`: How long to listen for responses
|
||||
- `opts`: Discovery options (including NetworkInterface)
|
||||
|
||||
**Returns:**
|
||||
- `[]*Device`: Discovered devices
|
||||
- `error`: Any error that occurred
|
||||
|
||||
#### `ListNetworkInterfaces() ([]NetworkInterface, error)`
|
||||
|
||||
Lists all available network interfaces with their details.
|
||||
|
||||
**Returns:**
|
||||
- `[]NetworkInterface`: All network interfaces
|
||||
- `error`: Any error that occurred
|
||||
|
||||
### NetworkInterface
|
||||
|
||||
```go
|
||||
type NetworkInterface struct {
|
||||
// Name of the interface (e.g., "eth0", "wlan0")
|
||||
Name string
|
||||
|
||||
// IP addresses assigned to this interface
|
||||
Addresses []string
|
||||
|
||||
// Up indicates if the interface is up
|
||||
Up bool
|
||||
|
||||
// Multicast indicates if the interface supports multicast
|
||||
Multicast bool
|
||||
}
|
||||
```
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
### Scenario 1: Multiple Ethernet and WiFi Interfaces
|
||||
|
||||
You have both Ethernet (eth0) and WiFi (wlan0), cameras are on Ethernet:
|
||||
|
||||
```go
|
||||
// List to see what's available
|
||||
interfaces, _ := discovery.ListNetworkInterfaces()
|
||||
for _, i := range interfaces {
|
||||
log.Printf("%s: %v", i.Name, i.Addresses)
|
||||
}
|
||||
|
||||
// Discover on Ethernet only
|
||||
opts := &discovery.DiscoverOptions{
|
||||
NetworkInterface: "eth0",
|
||||
}
|
||||
devices, _ := discovery.DiscoverWithOptions(ctx, 5*time.Second, opts)
|
||||
```
|
||||
|
||||
### Scenario 2: Virtual Machine with Multiple Adapters
|
||||
|
||||
VM has management interface and camera network interface:
|
||||
|
||||
```go
|
||||
// Use the camera network IP directly
|
||||
opts := &discovery.DiscoverOptions{
|
||||
NetworkInterface: "192.168.200.50", // Camera network segment
|
||||
}
|
||||
devices, _ := discovery.DiscoverWithOptions(ctx, 5*time.Second, opts)
|
||||
```
|
||||
|
||||
### Scenario 3: Docker Container with Custom Network
|
||||
|
||||
```go
|
||||
// Container has multiple networks, specify which one
|
||||
opts := &discovery.DiscoverOptions{
|
||||
NetworkInterface: "172.20.0.10", // Custom bridge network IP
|
||||
}
|
||||
devices, _ := discovery.DiscoverWithOptions(ctx, 5*time.Second, opts)
|
||||
```
|
||||
|
||||
### Scenario 4: CLI Tool with User Selection
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"github.com/0x524a/onvif-go/discovery"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ifaceFlag := flag.String("interface", "", "Network interface to use")
|
||||
flag.Parse()
|
||||
|
||||
if *ifaceFlag == "" {
|
||||
// List available if not specified
|
||||
interfaces, _ := discovery.ListNetworkInterfaces()
|
||||
fmt.Println("Available interfaces:")
|
||||
for _, i := range interfaces {
|
||||
fmt.Printf(" %s\n", i.Name)
|
||||
}
|
||||
fmt.Println("Use -interface flag to specify")
|
||||
return
|
||||
}
|
||||
|
||||
opts := &discovery.DiscoverOptions{
|
||||
NetworkInterface: *ifaceFlag,
|
||||
}
|
||||
|
||||
devices, _ := discovery.DiscoverWithOptions(ctx, 5*time.Second, opts)
|
||||
fmt.Printf("Found %d devices\n", len(devices))
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# List interfaces
|
||||
./app
|
||||
|
||||
# Available interfaces:
|
||||
# eth0
|
||||
# wlan0
|
||||
|
||||
# Discover on specific interface
|
||||
./app -interface eth0
|
||||
./app -interface wlan0
|
||||
./app -interface 192.168.1.100
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Interface Not Found
|
||||
|
||||
```go
|
||||
opts := &discovery.DiscoverOptions{
|
||||
NetworkInterface: "nonexistent-interface",
|
||||
}
|
||||
|
||||
devices, err := discovery.DiscoverWithOptions(ctx, 5*time.Second, opts)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
// Output:
|
||||
// network interface "nonexistent-interface" not found.
|
||||
// Available interfaces: [eth0 [192.168.1.100] wlan0 [192.168.88.50] ...]
|
||||
}
|
||||
```
|
||||
|
||||
### Invalid IP Address
|
||||
|
||||
```go
|
||||
opts := &discovery.DiscoverOptions{
|
||||
NetworkInterface: "192.168.999.999", // Invalid IP
|
||||
}
|
||||
|
||||
devices, err := discovery.DiscoverWithOptions(ctx, 5*time.Second, opts)
|
||||
if err != nil {
|
||||
// Error: network interface not found
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From: Using Default Discovery
|
||||
|
||||
```go
|
||||
// Old code - still works!
|
||||
devices, err := discovery.Discover(ctx, 5*time.Second)
|
||||
```
|
||||
|
||||
### To: Using Specific Interface
|
||||
|
||||
```go
|
||||
// New code - with interface selection
|
||||
opts := &discovery.DiscoverOptions{
|
||||
NetworkInterface: "eth0",
|
||||
}
|
||||
devices, err := discovery.DiscoverWithOptions(ctx, 5*time.Second, opts)
|
||||
```
|
||||
|
||||
No breaking changes - old code continues to work!
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "No devices found on interface X"
|
||||
|
||||
**Possible causes:**
|
||||
1. Cameras are on a different network segment
|
||||
2. Interface is not connected to the camera network
|
||||
3. Firewall is blocking multicast on that interface
|
||||
4. Camera network interface name is different than expected
|
||||
|
||||
**Solution:**
|
||||
```go
|
||||
// List interfaces to verify
|
||||
interfaces, _ := discovery.ListNetworkInterfaces()
|
||||
for _, i := range interfaces {
|
||||
if i.Up && i.Multicast {
|
||||
fmt.Printf("Try: %s (%v)\n", i.Name, i.Addresses)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### "Network interface not found"
|
||||
|
||||
**Possible causes:**
|
||||
1. Interface name typo (e.g., "eth0" vs "eth1")
|
||||
2. Interface is down
|
||||
3. IP address not assigned to any interface
|
||||
|
||||
**Solution:**
|
||||
- Check spelling: `discovery.ListNetworkInterfaces()`
|
||||
- Verify interface is up: `Up: true`
|
||||
- Verify IP is correct: Check `Addresses` field
|
||||
|
||||
### Multicast Not Supported
|
||||
|
||||
```go
|
||||
interfaces, _ := discovery.ListNetworkInterfaces()
|
||||
for _, i := range interfaces {
|
||||
if i.Multicast {
|
||||
fmt.Printf("%s supports multicast\n", i.Name)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always list interfaces first** if uncertain:
|
||||
```go
|
||||
interfaces, _ := discovery.ListNetworkInterfaces()
|
||||
// Show user and let them choose
|
||||
```
|
||||
|
||||
2. **Validate interface exists** before discovery:
|
||||
```go
|
||||
opts := &discovery.DiscoverOptions{
|
||||
NetworkInterface: userInput,
|
||||
}
|
||||
// Try with empty timeout first to validate
|
||||
```
|
||||
|
||||
3. **Try multiple interfaces** for robust applications:
|
||||
```go
|
||||
for _, iface := range interfaces {
|
||||
if iface.Up && iface.Multicast {
|
||||
opts := &discovery.DiscoverOptions{
|
||||
NetworkInterface: iface.Name,
|
||||
}
|
||||
devices, _ := discovery.DiscoverWithOptions(ctx, 2*time.Second, opts)
|
||||
if len(devices) > 0 {
|
||||
return devices
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Check interface capabilities**:
|
||||
```go
|
||||
for _, i := range interfaces {
|
||||
if i.Up && i.Multicast {
|
||||
// Good candidate for discovery
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run discovery tests
|
||||
go test -v ./discovery/
|
||||
|
||||
# Run with specific interface test
|
||||
go test -v ./discovery/ -run TestDiscoverWithOptions
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [QUICKSTART](../QUICKSTART.md) - Getting started with onvif-go
|
||||
- [discovery/discovery.go](./discovery.go) - Source code
|
||||
- [discovery/discovery_test.go](./discovery_test.go) - Test examples
|
||||
@@ -0,0 +1,390 @@
|
||||
// Package discovery provides ONVIF device discovery functionality using WS-Discovery protocol.
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// WS-Discovery multicast address.
|
||||
multicastAddr = "239.255.255.250:3702"
|
||||
// UUID generation constants.
|
||||
uuidMod1000 = 1000
|
||||
uuidMod10000 = 10000
|
||||
|
||||
// WS-Discovery probe message.
|
||||
probeTemplate = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" ` +
|
||||
`xmlns:a="http://schemas.xmlsoap.org/ws/2004/08/addressing">
|
||||
<s:Header>
|
||||
<a:Action s:mustUnderstand="1">` +
|
||||
`http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</a:Action>
|
||||
<a:MessageID>uuid:%s</a:MessageID>
|
||||
<a:ReplyTo>
|
||||
<a:Address>` +
|
||||
`http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</a:Address>
|
||||
</a:ReplyTo>
|
||||
<a:To s:mustUnderstand="1">` +
|
||||
`urn:schemas-xmlsoap-org:ws:2005:04:discovery</a:To>
|
||||
</s:Header>
|
||||
<s:Body>
|
||||
<Probe xmlns="http://schemas.xmlsoap.org/ws/2005/04/discovery">
|
||||
<d:Types xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery" ` +
|
||||
`xmlns:dp0="http://www.onvif.org/ver10/network/wsdl">` +
|
||||
`dp0:NetworkVideoTransmitter</d:Types>
|
||||
</Probe>
|
||||
</s:Body>
|
||||
</s:Envelope>`
|
||||
)
|
||||
|
||||
// Device represents a discovered ONVIF device.
|
||||
type Device struct {
|
||||
// Device endpoint address
|
||||
EndpointRef string
|
||||
|
||||
// XAddrs contains the device service addresses
|
||||
XAddrs []string
|
||||
|
||||
// Types contains the device types
|
||||
Types []string
|
||||
|
||||
// Scopes contains the device scopes (name, location, etc.)
|
||||
Scopes []string
|
||||
|
||||
// Metadata version
|
||||
MetadataVersion int
|
||||
}
|
||||
|
||||
// ProbeMatch represents a WS-Discovery probe match.
|
||||
type ProbeMatch struct {
|
||||
XMLName xml.Name `xml:"ProbeMatch"`
|
||||
EndpointRef string `xml:"EndpointReference>Address"`
|
||||
Types string `xml:"Types"`
|
||||
Scopes string `xml:"Scopes"`
|
||||
XAddrs string `xml:"XAddrs"`
|
||||
MetadataVersion int `xml:"MetadataVersion"`
|
||||
}
|
||||
|
||||
// ProbeMatches represents WS-Discovery probe matches.
|
||||
type ProbeMatches struct {
|
||||
XMLName xml.Name `xml:"ProbeMatches"`
|
||||
ProbeMatch []ProbeMatch `xml:"ProbeMatch"`
|
||||
}
|
||||
|
||||
// DiscoverOptions contains options for device discovery.
|
||||
type DiscoverOptions struct {
|
||||
// NetworkInterface specifies the network interface to use for multicast.
|
||||
// If empty, the system will choose the default interface.
|
||||
// Examples: "eth0", "wlan0", "192.168.1.100"
|
||||
NetworkInterface string
|
||||
|
||||
// Context and timeout are handled by the caller
|
||||
}
|
||||
|
||||
// Discover performs ONVIF device discovery using WS-Discovery protocol.
|
||||
// For advanced options like specifying a network interface, use DiscoverWithOptions.
|
||||
func Discover(ctx context.Context, timeout time.Duration) ([]*Device, error) {
|
||||
return DiscoverWithOptions(ctx, timeout, &DiscoverOptions{})
|
||||
}
|
||||
|
||||
// DiscoverWithOptions discovers ONVIF devices with custom options.
|
||||
//
|
||||
//nolint:gocyclo // Discovery function has high complexity due to multiple network operations
|
||||
func DiscoverWithOptions(ctx context.Context, timeout time.Duration, opts *DiscoverOptions) ([]*Device, error) {
|
||||
if opts == nil {
|
||||
opts = &DiscoverOptions{}
|
||||
}
|
||||
|
||||
// Create UDP connection for multicast
|
||||
addr, err := net.ResolveUDPAddr("udp", multicastAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve multicast address: %w", err)
|
||||
}
|
||||
|
||||
// Get the network interface to use
|
||||
var iface *net.Interface
|
||||
if opts.NetworkInterface != "" {
|
||||
iface, err = resolveNetworkInterface(opts.NetworkInterface)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve network interface: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := net.ListenMulticastUDP("udp", iface, addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to listen on multicast address: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
// Set read deadline
|
||||
if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
|
||||
return nil, fmt.Errorf("failed to set read deadline: %w", err)
|
||||
}
|
||||
|
||||
// Generate message ID
|
||||
messageID := generateUUID()
|
||||
|
||||
// Send probe message
|
||||
probeMsg := fmt.Sprintf(probeTemplate, messageID)
|
||||
if _, err := conn.WriteToUDP([]byte(probeMsg), addr); err != nil {
|
||||
return nil, fmt.Errorf("failed to send probe message: %w", err)
|
||||
}
|
||||
|
||||
// Collect responses
|
||||
devices := make(map[string]*Device)
|
||||
const maxUDPPacketSize = 8192
|
||||
buffer := make([]byte, maxUDPPacketSize)
|
||||
|
||||
// Read responses until timeout or context cancellation
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return deviceMapToSlice(devices), ctx.Err()
|
||||
default:
|
||||
n, _, err := conn.ReadFromUDP(buffer)
|
||||
if err != nil {
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
// Timeout reached, return collected devices
|
||||
return deviceMapToSlice(devices), nil
|
||||
}
|
||||
|
||||
return deviceMapToSlice(devices), fmt.Errorf("failed to read UDP response: %w", err)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
device, err := parseProbeResponse(buffer[:n])
|
||||
if err != nil {
|
||||
// Skip invalid responses
|
||||
continue
|
||||
}
|
||||
|
||||
// Add to devices map (deduplicate by endpoint)
|
||||
if device != nil && device.EndpointRef != "" {
|
||||
devices[device.EndpointRef] = device
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseProbeResponse parses a WS-Discovery probe response.
|
||||
func parseProbeResponse(data []byte) (*Device, error) {
|
||||
var envelope struct {
|
||||
Body struct {
|
||||
ProbeMatches ProbeMatches `xml:"ProbeMatches"`
|
||||
} `xml:"Body"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(data, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal probe response: %w", err)
|
||||
}
|
||||
|
||||
if len(envelope.Body.ProbeMatches.ProbeMatch) == 0 {
|
||||
return nil, fmt.Errorf("%w", ErrNoProbeMatches)
|
||||
}
|
||||
|
||||
// Take the first probe match
|
||||
match := envelope.Body.ProbeMatches.ProbeMatch[0]
|
||||
|
||||
device := &Device{
|
||||
EndpointRef: match.EndpointRef,
|
||||
XAddrs: parseSpaceSeparated(match.XAddrs),
|
||||
Types: parseSpaceSeparated(match.Types),
|
||||
Scopes: parseSpaceSeparated(match.Scopes),
|
||||
MetadataVersion: match.MetadataVersion,
|
||||
}
|
||||
|
||||
return device, nil
|
||||
}
|
||||
|
||||
// parseSpaceSeparated parses a space-separated string into a slice.
|
||||
func parseSpaceSeparated(s string) []string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
return strings.Fields(s)
|
||||
}
|
||||
|
||||
// deviceMapToSlice converts a map of devices to a slice.
|
||||
func deviceMapToSlice(m map[string]*Device) []*Device {
|
||||
devices := make([]*Device, 0, len(m))
|
||||
for _, device := range m {
|
||||
devices = append(devices, device)
|
||||
}
|
||||
|
||||
return devices
|
||||
}
|
||||
|
||||
// generateUUID generates a simple UUID (not cryptographically secure).
|
||||
func generateUUID() string {
|
||||
now := time.Now()
|
||||
nanos := now.UnixNano()
|
||||
secs := now.Unix()
|
||||
|
||||
return fmt.Sprintf("%d-%d-%d-%d-%d",
|
||||
nanos,
|
||||
secs,
|
||||
nanos%uuidMod1000,
|
||||
secs%uuidMod1000,
|
||||
nanos%uuidMod10000)
|
||||
}
|
||||
|
||||
// resolveNetworkInterface resolves a network interface by name or IP address.
|
||||
//
|
||||
//nolint:gocyclo,gocognit // Network interface resolution has high complexity due to multiple validation paths
|
||||
func resolveNetworkInterface(ifaceSpec string) (*net.Interface, error) {
|
||||
// Try to get interface by name (e.g., "eth0", "wlan0")
|
||||
if iface, err := net.InterfaceByName(ifaceSpec); err == nil {
|
||||
return iface, nil
|
||||
}
|
||||
|
||||
// Try to parse as IP address and find the interface
|
||||
if ip := net.ParseIP(ifaceSpec); ip != nil {
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list network interfaces: %w", err)
|
||||
}
|
||||
|
||||
for _, iface := range interfaces {
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
if v.IP.Equal(ip) {
|
||||
return &iface, nil
|
||||
}
|
||||
case *net.IPAddr:
|
||||
if v.IP.Equal(ip) {
|
||||
return &iface, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// List available interfaces for error message
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
interfaces = nil // Continue with empty list if we can't get interfaces
|
||||
}
|
||||
availableInterfaces := make([]string, 0)
|
||||
for _, iface := range interfaces {
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue // Skip this interface if we can't get addresses
|
||||
}
|
||||
ifaceInfo := iface.Name
|
||||
if len(addrs) > 0 {
|
||||
var addrStrs []string
|
||||
for _, addr := range addrs {
|
||||
addrStrs = append(addrStrs, addr.String())
|
||||
}
|
||||
ifaceInfo += " [" + strings.Join(addrStrs, ", ") + "]"
|
||||
}
|
||||
availableInterfaces = append(availableInterfaces, ifaceInfo)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("%w: %q. Available interfaces: %v", ErrNetworkInterfaceNotFound, ifaceSpec, availableInterfaces)
|
||||
}
|
||||
|
||||
// ListNetworkInterfaces returns all available network interfaces with their addresses.
|
||||
func ListNetworkInterfaces() ([]NetworkInterface, error) {
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list network interfaces: %w", err)
|
||||
}
|
||||
|
||||
result := make([]NetworkInterface, 0, len(interfaces))
|
||||
for _, iface := range interfaces {
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var ipAddrs []string
|
||||
for _, addr := range addrs {
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ipAddrs = append(ipAddrs, v.IP.String())
|
||||
case *net.IPAddr:
|
||||
ipAddrs = append(ipAddrs, v.IP.String())
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, NetworkInterface{
|
||||
Name: iface.Name,
|
||||
Addresses: ipAddrs,
|
||||
Up: iface.Flags&net.FlagUp != 0,
|
||||
Multicast: iface.Flags&net.FlagMulticast != 0,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// NetworkInterface represents a network interface.
|
||||
type NetworkInterface struct {
|
||||
// Name of the interface (e.g., "eth0", "wlan0")
|
||||
Name string
|
||||
|
||||
// IP addresses assigned to this interface
|
||||
Addresses []string
|
||||
|
||||
// Up indicates if the interface is up
|
||||
Up bool
|
||||
|
||||
// Multicast indicates if the interface supports multicast
|
||||
Multicast bool
|
||||
}
|
||||
|
||||
// GetDeviceEndpoint extracts the primary device endpoint from XAddrs.
|
||||
func (d *Device) GetDeviceEndpoint() string {
|
||||
if len(d.XAddrs) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Return the first XAddr
|
||||
return d.XAddrs[0]
|
||||
}
|
||||
|
||||
// GetName extracts the device name from scopes.
|
||||
func (d *Device) GetName() string {
|
||||
for _, scope := range d.Scopes {
|
||||
if strings.Contains(scope, "name") {
|
||||
parts := strings.Split(scope, "/")
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetLocation extracts the device location from scopes.
|
||||
func (d *Device) GetLocation() string {
|
||||
for _, scope := range d.Scopes {
|
||||
if strings.Contains(scope, "location") {
|
||||
parts := strings.Split(scope, "/")
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDevice_GetName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
device *Device
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "device with name in scopes",
|
||||
device: &Device{
|
||||
Scopes: []string{
|
||||
"onvif://www.onvif.org/name/TestCamera",
|
||||
"onvif://www.onvif.org/hardware/Model123",
|
||||
},
|
||||
},
|
||||
want: "TestCamera",
|
||||
},
|
||||
{
|
||||
name: "device without name in scopes",
|
||||
device: &Device{
|
||||
Scopes: []string{
|
||||
"onvif://www.onvif.org/hardware/Model123",
|
||||
},
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "device with no scopes",
|
||||
device: &Device{},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.device.GetName(); got != tt.want {
|
||||
t.Errorf("GetName() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevice_GetDeviceEndpoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
device *Device
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "device with valid XAddrs",
|
||||
device: &Device{
|
||||
XAddrs: []string{
|
||||
"http://192.168.1.100:80/onvif/device_service",
|
||||
"http://192.168.1.100:8080/onvif/device_service",
|
||||
},
|
||||
},
|
||||
want: "http://192.168.1.100:80/onvif/device_service",
|
||||
},
|
||||
{
|
||||
name: "device with no XAddrs",
|
||||
device: &Device{},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.device.GetDeviceEndpoint(); got != tt.want {
|
||||
t.Errorf("GetDeviceEndpoint() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevice_GetLocation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
device *Device
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "device with location in scopes",
|
||||
device: &Device{
|
||||
Scopes: []string{
|
||||
"onvif://www.onvif.org/location/Building1",
|
||||
"onvif://www.onvif.org/hardware/Model123",
|
||||
},
|
||||
},
|
||||
want: "Building1",
|
||||
},
|
||||
{
|
||||
name: "device without location in scopes",
|
||||
device: &Device{
|
||||
Scopes: []string{
|
||||
"onvif://www.onvif.org/hardware/Model123",
|
||||
},
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "device with no scopes",
|
||||
device: &Device{},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.device.GetLocation(); got != tt.want {
|
||||
t.Errorf("GetLocation() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscover_WithTimeout(t *testing.T) {
|
||||
// This test will timeout since there are likely no actual cameras on the test network
|
||||
// It validates that the timeout mechanism works
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
devices, err := Discover(ctx, 500*time.Millisecond)
|
||||
|
||||
// We expect either no error (empty devices list) or a timeout/context error
|
||||
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Logf("Discover returned error: %v (this is expected in test environment)", err)
|
||||
}
|
||||
|
||||
// Devices might be empty in test environment
|
||||
t.Logf("Discovered %d devices", len(devices))
|
||||
}
|
||||
|
||||
func TestDiscover_InvalidDuration(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test with zero duration
|
||||
devices, err := Discover(ctx, 0)
|
||||
if err != nil {
|
||||
t.Logf("Discovery with 0 duration returned error: %v", err)
|
||||
}
|
||||
t.Logf("Discovered %d devices with 0 duration", len(devices))
|
||||
}
|
||||
|
||||
func TestParseSpaceSeparated(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "multiple values",
|
||||
input: "value1 value2 value3",
|
||||
want: []string{"value1", "value2", "value3"},
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "single value",
|
||||
input: "value1",
|
||||
want: []string{"value1"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseSpaceSeparated(tt.input)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("parseSpaceSeparated() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevice_GetTypes(t *testing.T) {
|
||||
device := &Device{
|
||||
Types: []string{
|
||||
"dn:NetworkVideoTransmitter",
|
||||
"tds:Device",
|
||||
},
|
||||
}
|
||||
|
||||
types := device.Types
|
||||
if len(types) != 2 {
|
||||
t.Errorf("Expected 2 types, got %d", len(types))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevice_GetScopes(t *testing.T) {
|
||||
scopes := []string{
|
||||
"onvif://www.onvif.org/name/TestCamera",
|
||||
"onvif://www.onvif.org/location/Building1",
|
||||
"onvif://www.onvif.org/hardware/Model123",
|
||||
}
|
||||
|
||||
device := &Device{
|
||||
Scopes: scopes,
|
||||
}
|
||||
|
||||
if len(device.Scopes) != 3 {
|
||||
t.Errorf("Expected 3 scopes, got %d", len(device.Scopes))
|
||||
}
|
||||
|
||||
// Test specific scope extraction
|
||||
hasName := false
|
||||
for _, scope := range device.Scopes {
|
||||
if scope != "" && scope[:5] == "onvif" {
|
||||
hasName = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasName {
|
||||
t.Error("Expected to find onvif scope")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDeviceGetName(b *testing.B) {
|
||||
device := &Device{
|
||||
Scopes: []string{
|
||||
"onvif://www.onvif.org/name/TestCamera",
|
||||
"onvif://www.onvif.org/hardware/Model123",
|
||||
},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = device.GetName()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDeviceGetDeviceEndpoint(b *testing.B) {
|
||||
device := &Device{
|
||||
XAddrs: []string{
|
||||
"http://192.168.1.100/onvif/device_service",
|
||||
"http://192.168.1.100:8080/onvif/device_service",
|
||||
},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = device.GetDeviceEndpoint()
|
||||
}
|
||||
}
|
||||
|
||||
// Tests for network interface discovery features
|
||||
|
||||
func TestListNetworkInterfaces(t *testing.T) {
|
||||
interfaces, err := ListNetworkInterfaces()
|
||||
if err != nil {
|
||||
t.Fatalf("ListNetworkInterfaces failed: %v", err)
|
||||
}
|
||||
|
||||
if len(interfaces) == 0 {
|
||||
t.Skip("No network interfaces available")
|
||||
}
|
||||
|
||||
// Verify loopback interface exists (if available)
|
||||
for _, iface := range interfaces {
|
||||
if iface.Name == "lo" {
|
||||
if len(iface.Addresses) == 0 {
|
||||
t.Error("Loopback interface should have addresses")
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Loopback might not exist on all systems, but there should be at least one interface
|
||||
t.Logf("Found %d network interface(s)", len(interfaces))
|
||||
for _, iface := range interfaces {
|
||||
t.Logf(" - %s: up=%v, multicast=%v, addresses=%v", iface.Name, iface.Up, iface.Multicast, iface.Addresses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNetworkInterface(t *testing.T) {
|
||||
// Determine the loopback interface name based on platform
|
||||
loopbackName := "lo"
|
||||
if _, err := net.InterfaceByName("lo"); err != nil {
|
||||
// Loopback might be "lo0" on macOS
|
||||
loopbackName = "lo0"
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ifaceSpec string
|
||||
shouldErr bool
|
||||
}{
|
||||
{
|
||||
name: "loopback by name",
|
||||
ifaceSpec: loopbackName,
|
||||
shouldErr: false,
|
||||
},
|
||||
{
|
||||
name: "loopback by ip",
|
||||
ifaceSpec: "127.0.0.1",
|
||||
shouldErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid interface",
|
||||
ifaceSpec: "nonexistent-interface-12345xyz",
|
||||
shouldErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid ip",
|
||||
ifaceSpec: "999.999.999.999",
|
||||
shouldErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
iface, err := resolveNetworkInterface(tt.ifaceSpec)
|
||||
|
||||
if tt.shouldErr {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for interface %s, but got none", tt.ifaceSpec)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for interface %s: %v", tt.ifaceSpec, err)
|
||||
}
|
||||
if iface == nil {
|
||||
t.Errorf("Expected interface for %s, but got nil", tt.ifaceSpec)
|
||||
} else {
|
||||
t.Logf("Resolved %s to interface: %s", tt.ifaceSpec, iface.Name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverWithOptions_DefaultOptions(t *testing.T) {
|
||||
// Test with default options (should not error even if no cameras found)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
devices, err := DiscoverWithOptions(ctx, 1*time.Second, &DiscoverOptions{})
|
||||
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Logf("DiscoverWithOptions returned: %v (this is OK if no cameras on network)", err)
|
||||
}
|
||||
|
||||
// Should return a slice (possibly empty)
|
||||
if devices == nil {
|
||||
t.Error("Expected devices slice, got nil")
|
||||
}
|
||||
|
||||
t.Logf("Found %d devices with default options", len(devices))
|
||||
}
|
||||
|
||||
func TestDiscoverWithOptions_NilOptions(t *testing.T) {
|
||||
// Test with nil options (should work with nil)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
devices, err := DiscoverWithOptions(ctx, 500*time.Millisecond, nil)
|
||||
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Logf("DiscoverWithOptions with nil returned: %v", err)
|
||||
}
|
||||
|
||||
if devices == nil {
|
||||
t.Error("Expected devices slice, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverWithOptions_LoopbackInterface(t *testing.T) {
|
||||
// Test with loopback interface for testing
|
||||
// Try both common loopback names
|
||||
loopbackName := ""
|
||||
if _, err := net.InterfaceByName("lo"); err == nil {
|
||||
loopbackName = "lo"
|
||||
} else if _, err := net.InterfaceByName("lo0"); err == nil {
|
||||
loopbackName = "lo0"
|
||||
} else {
|
||||
t.Skip("Loopback interface not available on this system")
|
||||
}
|
||||
|
||||
opts := &DiscoverOptions{
|
||||
NetworkInterface: loopbackName,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
devices, err := DiscoverWithOptions(ctx, 500*time.Millisecond, opts)
|
||||
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Logf("DiscoverWithOptions with %s interface: %v (timeout is expected)", loopbackName, err)
|
||||
}
|
||||
|
||||
if devices == nil {
|
||||
t.Error("Expected devices slice, got nil")
|
||||
}
|
||||
|
||||
t.Logf("Found %d devices on loopback interface", len(devices))
|
||||
}
|
||||
|
||||
func TestDiscoverWithOptions_InvalidInterface(t *testing.T) {
|
||||
opts := &DiscoverOptions{
|
||||
NetworkInterface: "nonexistent-interface-xyz",
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := DiscoverWithOptions(ctx, 500*time.Millisecond, opts)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid interface, but got none")
|
||||
}
|
||||
|
||||
t.Logf("Got expected error: %v", err)
|
||||
}
|
||||
|
||||
func TestDiscover_BackwardCompatibility(t *testing.T) {
|
||||
// Test that old Discover function still works (backward compatibility)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
devices, err := Discover(ctx, 500*time.Millisecond)
|
||||
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Logf("Discover returned: %v", err)
|
||||
}
|
||||
|
||||
if devices == nil {
|
||||
t.Error("Expected devices slice, got nil")
|
||||
}
|
||||
|
||||
t.Logf("Backward compat: found %d devices", len(devices))
|
||||
}
|
||||
|
||||
func BenchmarkListNetworkInterfaces(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = ListNetworkInterfaces()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkResolveNetworkInterface(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = resolveNetworkInterface("127.0.0.1")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Package discovery provides error definitions for the discovery package.
|
||||
package discovery
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// ErrNoProbeMatches is returned when no probe matches are found during discovery.
|
||||
ErrNoProbeMatches = errors.New("no probe matches found")
|
||||
|
||||
// ErrNetworkInterfaceNotFound is returned when a network interface is not found.
|
||||
ErrNetworkInterfaceNotFound = errors.New("network interface not found")
|
||||
)
|
||||
Reference in New Issue
Block a user