Add or update .codecov copy.yml

This commit is contained in:
ProtoTess
2026-01-16 04:11:59 +00:00
parent ef340c0e5a
commit 66f6a4e838
391 changed files with 131885 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
// Command discover performs ONVIF camera discovery on the local network.
package main
import (
"context"
"flag"
"fmt"
"os"
"time"
"github.com/0x524a/onvif-go/discovery"
)
func main() {
iface := flag.String("interface", "", "Network interface to use (e.g., en0, en11)")
timeout := flag.Duration("timeout", 10*time.Second, "Discovery timeout")
flag.Parse()
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
opts := &discovery.DiscoverOptions{
NetworkInterface: *iface,
}
fmt.Printf("Discovering ONVIF cameras on the network")
if *iface != "" {
fmt.Printf(" (interface: %s)", *iface)
}
fmt.Println("...")
devices, err := discovery.DiscoverWithOptions(ctx, *timeout, opts)
if err != nil {
fmt.Fprintf(os.Stderr, "Discovery error: %v\n", err)
os.Exit(1)
}
if len(devices) == 0 {
fmt.Println("No cameras found.")
os.Exit(0)
}
fmt.Printf("\nFound %d camera(s):\n\n", len(devices))
for i, d := range devices {
fmt.Printf("Camera %d:\n", i+1)
fmt.Printf(" Endpoint: %s\n", d.EndpointRef)
for _, addr := range d.XAddrs {
fmt.Printf(" XAddr: %s\n", addr)
}
if len(d.Scopes) > 0 {
fmt.Printf(" Scopes:\n")
for _, s := range d.Scopes {
fmt.Printf(" - %s\n", s)
}
}
fmt.Println()
}
}
+236
View File
@@ -0,0 +1,236 @@
# Test Generator
Automatically generate Go tests from captured ONVIF camera XML traffic.
## Overview
This tool reads XML capture archives (created by `onvif-diagnostics -capture-xml`) and generates complete Go test files that replay the captured SOAP traffic through a mock server.
## Usage
### Basic Usage
```bash
./generate-tests \
-capture camera-logs/Camera_Model_xmlcapture_timestamp.tar.gz \
-output testdata/captures/
```
### Options
```
-capture string
Path to XML capture archive (.tar.gz) (required)
-output string
Output directory for generated test file (default: "./")
-package string
Package name for generated test (default: "onvif_test")
```
## Example
```bash
# Generate test from Bosch camera capture
./generate-tests \
-capture camera-logs/Bosch_FLEXIDOME_indoor_5100i_IR_8.71.0066_xmlcapture_20251110-120000.tar.gz \
-output testdata/captures/
# Output:
# ✓ Generated test file: testdata/captures/bosch_flexidome_indoor_5100i_ir_8.71.0066_test.go
# Camera: Bosch FLEXIDOME indoor 5100i IR (Firmware: 8.71.0066)
# Captured operations: 18
```
## Generated Test Structure
The tool creates a complete test file with:
### Test Function
```go
func Test<CameraName>(t *testing.T)
```
Named based on camera manufacturer, model, and firmware.
### Subtests
- `GetDeviceInformation` - Validates device info parsing
- `GetSystemDateAndTime` - Tests date/time operation
- `GetCapabilities` - Verifies capability discovery
- `GetProfiles` - Tests media profile enumeration
### Assertions
Each subtest includes:
- Error checking
- Nil validation
- Basic field validation
- Informative logging
## How It Works
1. **Load Capture** - Reads all SOAP exchanges from tar.gz archive
2. **Extract Metadata** - Gets camera manufacturer, model, firmware from responses
3. **Generate Name** - Creates valid Go identifier from camera info
4. **Render Template** - Fills in test template with camera-specific data
5. **Write File** - Saves test to output directory
## Template
The generator uses an embedded Go template that creates:
```go
package onvif_test
import (
"context"
"testing"
"time"
"github.com/0x524a/onvif-go"
onviftesting "github.com/0x524a/onvif-go/testing"
)
func Test<CameraName>(t *testing.T) {
captureArchive := "<archive-file>.tar.gz"
mockServer, err := onviftesting.NewMockSOAPServer(captureArchive)
if err != nil {
t.Fatalf("Failed to create mock server: %v", err)
}
defer mockServer.Close()
client, err := onvif.NewClient(
mockServer.URL()+"/onvif/device_service",
onvif.WithCredentials("testuser", "testpass"),
)
// ... test operations
}
```
## Workflow
### 1. Capture from Camera
```bash
./onvif-diagnostics \
-endpoint "http://camera/onvif/device_service" \
-username "user" \
-password "pass" \
-capture-xml
```
### 2. Generate Test
```bash
./generate-tests \
-capture camera-logs/Camera_*_xmlcapture_*.tar.gz \
-output testdata/captures/
```
### 3. Run Test
```bash
go test -v ./testdata/captures/ -run TestCamera
```
## Customization
After generation, you can customize the test:
### Add Camera-Specific Tests
```go
t.Run("CustomFeature", func(t *testing.T) {
// Add custom test for camera-specific features
})
```
### Add Detailed Assertions
```go
t.Run("GetDeviceInformation", func(t *testing.T) {
info, err := client.GetDeviceInformation(ctx)
if err != nil {
t.Errorf("GetDeviceInformation failed: %v", err)
return
}
// Add specific assertions
if info.Manufacturer != "ExpectedManufacturer" {
t.Errorf("Expected manufacturer X, got %s", info.Manufacturer)
}
})
```
## Building
```bash
go build -o generate-tests ./cmd/generate-tests/
```
## Dependencies
- `github.com/0x524a/onvif-go/testing` - Mock server and capture loader
## Output File Naming
Generated test files are named:
```
<manufacturer>_<model>_<firmware>_test.go
```
Examples:
- `bosch_flexidome_indoor_5100i_ir_8.71.0066_test.go`
- `axis_q3626-ve_12.6.104_test.go`
- `reolink_e1_zoom_v3.1.0.2649_test.go`
All special characters converted to underscores or removed.
## Archive Path Handling
The generator automatically handles archive paths:
- If archive is in output directory, uses filename only
- Otherwise uses relative path from output directory
- Tests can find archives when run with `go test ./testdata/captures/`
## Troubleshooting
### "Failed to load capture"
Archive file not found or corrupted.
**Solution**: Verify archive path and ensure it's a valid tar.gz file.
### "Failed to extract device info"
Archive doesn't contain GetDeviceInformation response.
**Solution**: Re-capture from camera, ensuring diagnostic runs fully.
### Generated test won't compile
Usually due to invalid characters in camera names.
**Solution**: The generator should handle this, but you can manually edit the test function name.
## Future Enhancements
Potential improvements:
- [ ] Detect camera-specific operations (PTZ, audio, etc.)
- [ ] Generate profile-specific tests
- [ ] Add benchmarking subtests
- [ ] Support custom test templates
- [ ] Batch generation from multiple captures
## See Also
- `testdata/captures/README.md` - Using generated tests
- `testing/mock_server.go` - Mock server implementation
- `cmd/onvif-diagnostics/` - Capturing tool
+926
View File
@@ -0,0 +1,926 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"text/template"
"time"
onviftesting "github.com/0x524a/onvif-go/testing"
)
var (
captureArchive = flag.String("capture", "", "Path to XML capture archive (.tar.gz)")
outputDir = flag.String("output", "./", "Output directory for generated test file")
packageName = flag.String("package", "onvif_test", "Package name for generated test")
updateRegistry = flag.Bool("update-registry", true, "Update registry.json with camera info")
registryPath = flag.String("registry", "", "Path to registry.json (default: testdata/captures/registry.json)")
coverageReport = flag.Bool("coverage-report", false, "Generate coverage report from registry")
coverageOutput = flag.String("coverage-output", "", "Output path for coverage report (default: stdout)")
)
const testTemplate = `package {{.PackageName}}
import (
"context"
"testing"
"time"
"github.com/0x524a/onvif-go"
onviftesting "github.com/0x524a/onvif-go/testing"
)
// Test{{.CameraName}} tests ONVIF client against {{.CameraDescription}} captured responses.
// Capture format: V2 with parameter-aware matching
// Total captured operations: {{.TotalExchanges}}
func Test{{.CameraName}}(t *testing.T) {
// Load capture archive (relative to project root)
captureArchive := "{{.CaptureArchiveRelPath}}"
mockServer, err := onviftesting.NewMockSOAPServerV2(captureArchive)
if err != nil {
t.Fatalf("Failed to create mock server: %v", err)
}
defer mockServer.Close()
// Create ONVIF client pointing to mock server
client, err := onvif.NewClient(
mockServer.URL()+"/onvif/device_service",
onvif.WithCredentials("testuser", "testpass"),
)
if err != nil {
t.Fatalf("Failed to create ONVIF client: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// =========================================================================
// Device Service Operations
// =========================================================================
{{range .DeviceTests}}
t.Run("{{.Name}}", func(t *testing.T) {
{{.Code}}
})
{{end}}
// =========================================================================
// Media Service Operations
// =========================================================================
{{if .NeedsInit}}
// Initialize to discover service endpoints (required for Media/PTZ/Imaging)
if err := client.Initialize(ctx); err != nil {
t.Fatalf("Failed to initialize client: %v", err)
}
{{end}}
{{range .MediaTests}}
t.Run("{{.Name}}", func(t *testing.T) {
{{.Code}}
})
{{end}}
// =========================================================================
// Profile-Dependent Operations
// =========================================================================
{{range .ProfileTests}}
t.Run("{{.Name}}", func(t *testing.T) {
{{.Code}}
})
{{end}}
// =========================================================================
// PTZ Operations
// =========================================================================
{{range .PTZTests}}
t.Run("{{.Name}}", func(t *testing.T) {
{{.Code}}
})
{{end}}
// =========================================================================
// Imaging Operations
// =========================================================================
{{range .ImagingTests}}
t.Run("{{.Name}}", func(t *testing.T) {
{{.Code}}
})
{{end}}
}
`
type TestData struct {
PackageName string
CameraName string
CameraDescription string
CaptureArchiveRelPath string
TotalExchanges int
NeedsInit bool
DeviceTests []GeneratedTest
MediaTests []GeneratedTest
ProfileTests []GeneratedTest
PTZTests []GeneratedTest
ImagingTests []GeneratedTest
}
type GeneratedTest struct {
Name string
Code string
}
// operationInfo holds info about captured operations
type operationInfo struct {
OperationName string
ServiceType onviftesting.ServiceType
Parameters map[string]interface{}
Success bool
}
func main() {
flag.Parse()
// Set default registry path
regPath := *registryPath
if regPath == "" {
regPath = onviftesting.DefaultRegistryPath
}
// Handle coverage report mode
if *coverageReport {
generateCoverageReport(regPath)
return
}
if *captureArchive == "" {
fmt.Println("Error: -capture flag is required")
fmt.Println()
fmt.Println("Usage:")
flag.PrintDefaults()
fmt.Println()
fmt.Println("Example:")
fmt.Println(" ./generate-tests -capture camera-logs/Bosch_FLEXIDOME_indoor_5100i_IR_8.71.0066_xmlcapture_*.tar.gz")
fmt.Println()
fmt.Println("Coverage report:")
fmt.Println(" ./generate-tests -coverage-report")
os.Exit(1)
}
outputFile := generateTests()
// Update registry if requested
if *updateRegistry {
updateCameraRegistry(regPath, *captureArchive, outputFile)
}
}
func generateTests() string {
// Load capture with V2 support
capture, metadata, err := onviftesting.LoadCaptureFromArchiveV2(*captureArchive)
if err != nil {
log.Fatalf("Failed to load capture: %v", err)
}
// Extract camera name from archive filename
baseName := filepath.Base(*captureArchive)
parts := strings.Split(baseName, "_xmlcapture_")
cameraID := parts[0]
// Convert to valid Go identifier
cameraName := strings.ReplaceAll(cameraID, "-", "")
cameraName = strings.ReplaceAll(cameraName, ".", "")
cameraName = strings.ReplaceAll(cameraName, " ", "")
// Get camera description from metadata or extract from captures
cameraDesc := cameraID
if metadata != nil && metadata.CameraInfo.Manufacturer != "" {
cameraDesc = fmt.Sprintf("%s %s (Firmware: %s)",
metadata.CameraInfo.Manufacturer,
metadata.CameraInfo.Model,
metadata.CameraInfo.FirmwareVersion)
} else {
// Try to extract from GetDeviceInformation response
for _, ex := range capture.Exchanges {
if ex.OperationName == "GetDeviceInformation" && ex.Success {
manufacturer := extractXMLValue(ex.ResponseBody, "Manufacturer")
model := extractXMLValue(ex.ResponseBody, "Model")
firmware := extractXMLValue(ex.ResponseBody, "FirmwareVersion")
if manufacturer != "" && model != "" {
cameraDesc = fmt.Sprintf("%s %s (Firmware: %s)", manufacturer, model, firmware)
}
break
}
}
}
// Analyze captured operations
ops := analyzeOperations(capture)
// Generate tests by service type
testData := TestData{
PackageName: *packageName,
CameraName: cameraName,
CameraDescription: cameraDesc,
CaptureArchiveRelPath: makeRelativePath(*captureArchive, *outputDir),
TotalExchanges: len(capture.Exchanges),
NeedsInit: hasNonDeviceOperations(ops),
DeviceTests: generateDeviceTests(ops),
MediaTests: generateMediaTests(ops),
ProfileTests: generateProfileDependentTests(ops),
PTZTests: generatePTZTests(ops),
ImagingTests: generateImagingTests(ops),
}
// Generate test file
tmpl, err := template.New("test").Parse(testTemplate)
if err != nil {
log.Fatalf("Failed to parse template: %v", err)
}
outputFile := filepath.Join(*outputDir, fmt.Sprintf("%s_test.go", strings.ToLower(cameraID)))
f, err := os.Create(outputFile) //nolint:gosec // Filename is generated from test data, safe
if err != nil {
log.Fatalf("Failed to create output file: %v", err)
}
defer func() {
_ = f.Close()
}()
if err := tmpl.Execute(f, testData); err != nil {
_ = f.Close()
log.Fatalf("Failed to execute template: %v", err)
}
fmt.Printf("✓ Generated test file: %s\n", outputFile)
fmt.Printf(" Camera: %s\n", cameraDesc)
fmt.Printf(" Captured operations: %d\n", len(capture.Exchanges))
fmt.Printf(" Generated subtests: Device=%d, Media=%d, Profile=%d, PTZ=%d, Imaging=%d\n",
len(testData.DeviceTests), len(testData.MediaTests), len(testData.ProfileTests),
len(testData.PTZTests), len(testData.ImagingTests))
fmt.Println()
fmt.Println("Run tests with:")
fmt.Printf(" go test -v %s\n", outputFile)
return outputFile
}
func analyzeOperations(capture *onviftesting.CameraCaptureV2) []operationInfo {
var ops []operationInfo
seen := make(map[string]bool)
for _, ex := range capture.Exchanges {
// Create unique key for deduplication
key := ex.OperationName
if token := ex.GetProfileToken(); token != "" {
key += "_" + token
} else if token := ex.GetConfigurationToken(); token != "" {
key += "_" + token
} else if token := ex.GetVideoSourceToken(); token != "" {
key += "_" + token
}
if seen[key] {
continue
}
seen[key] = true
ops = append(ops, operationInfo{
OperationName: ex.OperationName,
ServiceType: ex.ServiceType,
Parameters: ex.Parameters,
Success: ex.Success,
})
}
return ops
}
func hasNonDeviceOperations(ops []operationInfo) bool {
for _, op := range ops {
switch op.ServiceType {
case onviftesting.ServiceMedia, onviftesting.ServicePTZ, onviftesting.ServiceImaging:
return true
}
}
return false
}
func generateDeviceTests(ops []operationInfo) []GeneratedTest {
var tests []GeneratedTest
// Standard device tests
deviceOps := map[string]string{
"GetDeviceInformation": `info, err := client.GetDeviceInformation(ctx)
if err != nil {
t.Errorf("GetDeviceInformation failed: %v", err)
return
}
if info.Manufacturer == "" {
t.Error("Manufacturer is empty")
}
if info.Model == "" {
t.Error("Model is empty")
}
t.Logf("Device: %s %s (Firmware: %s)", info.Manufacturer, info.Model, info.FirmwareVersion)`,
"GetSystemDateAndTime": `_, err := client.GetSystemDateAndTime(ctx)
if err != nil {
t.Errorf("GetSystemDateAndTime failed: %v", err)
}`,
"GetCapabilities": `caps, err := client.GetCapabilities(ctx)
if err != nil {
t.Errorf("GetCapabilities failed: %v", err)
return
}
t.Logf("Capabilities: Device=%v, Media=%v, Imaging=%v, PTZ=%v",
caps.Device != nil, caps.Media != nil, caps.Imaging != nil, caps.PTZ != nil)`,
"GetHostname": `hostname, err := client.GetHostname(ctx)
if err != nil {
t.Errorf("GetHostname failed: %v", err)
return
}
t.Logf("Hostname: %s", hostname)`,
"GetScopes": `scopes, err := client.GetScopes(ctx)
if err != nil {
t.Errorf("GetScopes failed: %v", err)
return
}
t.Logf("Scopes: %d", len(scopes))`,
"GetNetworkInterfaces": `interfaces, err := client.GetNetworkInterfaces(ctx)
if err != nil {
t.Errorf("GetNetworkInterfaces failed: %v", err)
return
}
t.Logf("Network interfaces: %d", len(interfaces))`,
"GetServices": `services, err := client.GetServices(ctx, true)
if err != nil {
t.Errorf("GetServices failed: %v", err)
return
}
t.Logf("Services: %d", len(services))`,
}
// Generate tests for captured operations
for _, op := range ops {
if op.ServiceType != onviftesting.ServiceDevice && op.ServiceType != onviftesting.ServiceUnknown {
continue
}
if code, ok := deviceOps[op.OperationName]; ok {
tests = append(tests, GeneratedTest{
Name: op.OperationName,
Code: code,
})
delete(deviceOps, op.OperationName) // Don't duplicate
}
}
// Sort by name for consistent output
sort.Slice(tests, func(i, j int) bool {
return tests[i].Name < tests[j].Name
})
return tests
}
func generateMediaTests(ops []operationInfo) []GeneratedTest {
var tests []GeneratedTest
mediaOps := map[string]string{
"GetProfiles": `profiles, err := client.GetProfiles(ctx)
if err != nil {
t.Errorf("GetProfiles failed: %v", err)
return
}
if len(profiles) == 0 {
t.Error("No profiles returned")
}
t.Logf("Found %d profile(s)", len(profiles))`,
"GetVideoSources": `sources, err := client.GetVideoSources(ctx)
if err != nil {
t.Errorf("GetVideoSources failed: %v", err)
return
}
t.Logf("Video sources: %d", len(sources))`,
"GetVideoSourceConfigurations": `configs, err := client.GetVideoSourceConfigurations(ctx)
if err != nil {
t.Errorf("GetVideoSourceConfigurations failed: %v", err)
return
}
t.Logf("Video source configs: %d", len(configs))`,
"GetVideoEncoderConfigurations": `configs, err := client.GetVideoEncoderConfigurations(ctx)
if err != nil {
t.Errorf("GetVideoEncoderConfigurations failed: %v", err)
return
}
t.Logf("Video encoder configs: %d", len(configs))`,
"GetAudioSources": `sources, err := client.GetAudioSources(ctx)
if err != nil {
t.Errorf("GetAudioSources failed: %v", err)
return
}
t.Logf("Audio sources: %d", len(sources))`,
"GetAudioSourceConfigurations": `configs, err := client.GetAudioSourceConfigurations(ctx)
if err != nil {
t.Errorf("GetAudioSourceConfigurations failed: %v", err)
return
}
t.Logf("Audio source configs: %d", len(configs))`,
"GetMetadataConfigurations": `configs, err := client.GetMetadataConfigurations(ctx)
if err != nil {
t.Errorf("GetMetadataConfigurations failed: %v", err)
return
}
t.Logf("Metadata configs: %d", len(configs))`,
}
for _, op := range ops {
if op.ServiceType != onviftesting.ServiceMedia {
continue
}
if code, ok := mediaOps[op.OperationName]; ok {
tests = append(tests, GeneratedTest{
Name: op.OperationName,
Code: code,
})
delete(mediaOps, op.OperationName)
}
}
sort.Slice(tests, func(i, j int) bool {
return tests[i].Name < tests[j].Name
})
return tests
}
func generateProfileDependentTests(ops []operationInfo) []GeneratedTest {
var tests []GeneratedTest
// Group operations by profile token
profileOps := make(map[string][]operationInfo)
for _, op := range ops {
if token, ok := op.Parameters["ProfileToken"].(string); ok && token != "" {
profileOps[token] = append(profileOps[token], op)
}
}
// Generate GetStreamURI tests for each profile
for token, opList := range profileOps {
for _, op := range opList {
switch op.OperationName {
case "GetStreamURI":
testName := fmt.Sprintf("GetStreamURI_%s", sanitizeToken(token))
tests = append(tests, GeneratedTest{
Name: testName,
Code: fmt.Sprintf(`uri, err := client.GetStreamURI(ctx, "%s")
if err != nil {
t.Errorf("GetStreamURI failed: %%v", err)
return
}
if uri.URI == "" {
t.Error("Stream URI is empty")
}
t.Logf("Stream URI: %%s", uri.URI)`, token),
})
case "GetSnapshotURI":
testName := fmt.Sprintf("GetSnapshotURI_%s", sanitizeToken(token))
tests = append(tests, GeneratedTest{
Name: testName,
Code: fmt.Sprintf(`uri, err := client.GetSnapshotURI(ctx, "%s")
if err != nil {
t.Errorf("GetSnapshotURI failed: %%v", err)
return
}
if uri.URI == "" {
t.Error("Snapshot URI is empty")
}
t.Logf("Snapshot URI: %%s", uri.URI)`, token),
})
case "GetProfile":
testName := fmt.Sprintf("GetProfile_%s", sanitizeToken(token))
tests = append(tests, GeneratedTest{
Name: testName,
Code: fmt.Sprintf(`profile, err := client.GetProfile(ctx, "%s")
if err != nil {
t.Errorf("GetProfile failed: %%v", err)
return
}
if profile.Token != "%s" {
t.Errorf("Expected token %%s, got %%s", "%s", profile.Token)
}
t.Logf("Profile: %%s", profile.Name)`, token, token, token),
})
}
}
}
// Deduplicate tests
seen := make(map[string]bool)
var uniqueTests []GeneratedTest
for _, t := range tests {
if !seen[t.Name] {
seen[t.Name] = true
uniqueTests = append(uniqueTests, t)
}
}
sort.Slice(uniqueTests, func(i, j int) bool {
return uniqueTests[i].Name < uniqueTests[j].Name
})
return uniqueTests
}
func generatePTZTests(ops []operationInfo) []GeneratedTest {
var tests []GeneratedTest
ptzOps := map[string]string{
"GetNodes": `nodes, err := client.GetNodes(ctx)
if err != nil {
t.Errorf("GetNodes failed: %v", err)
return
}
t.Logf("PTZ nodes: %d", len(nodes))`,
"GetConfigurations": `configs, err := client.GetConfigurations(ctx)
if err != nil {
t.Errorf("GetConfigurations failed: %v", err)
return
}
t.Logf("PTZ configs: %d", len(configs))`,
}
// Group by profile token for status and presets
profileOps := make(map[string][]operationInfo)
for _, op := range ops {
if op.ServiceType != onviftesting.ServicePTZ {
continue
}
if code, ok := ptzOps[op.OperationName]; ok {
tests = append(tests, GeneratedTest{
Name: op.OperationName,
Code: code,
})
delete(ptzOps, op.OperationName)
continue
}
if token, ok := op.Parameters["ProfileToken"].(string); ok && token != "" {
profileOps[token] = append(profileOps[token], op)
}
}
// Generate profile-specific PTZ tests
for token, opList := range profileOps {
for _, op := range opList {
switch op.OperationName {
case "GetStatus":
testName := fmt.Sprintf("PTZ_GetStatus_%s", sanitizeToken(token))
tests = append(tests, GeneratedTest{
Name: testName,
Code: fmt.Sprintf(`status, err := client.GetStatus(ctx, "%s")
if err != nil {
t.Errorf("GetStatus failed: %%v", err)
return
}
t.Logf("PTZ Status retrieved for profile %s")
_ = status`, token, token),
})
case "GetPresets":
testName := fmt.Sprintf("PTZ_GetPresets_%s", sanitizeToken(token))
tests = append(tests, GeneratedTest{
Name: testName,
Code: fmt.Sprintf(`presets, err := client.GetPresets(ctx, "%s")
if err != nil {
t.Errorf("GetPresets failed: %%v", err)
return
}
t.Logf("Found %%d preset(s) for profile %s", len(presets))`, token, token),
})
}
}
}
// Deduplicate
seen := make(map[string]bool)
var uniqueTests []GeneratedTest
for _, t := range tests {
if !seen[t.Name] {
seen[t.Name] = true
uniqueTests = append(uniqueTests, t)
}
}
sort.Slice(uniqueTests, func(i, j int) bool {
return uniqueTests[i].Name < uniqueTests[j].Name
})
return uniqueTests
}
func generateImagingTests(ops []operationInfo) []GeneratedTest {
var tests []GeneratedTest
// Group by video source token
sourceOps := make(map[string][]operationInfo)
for _, op := range ops {
if op.ServiceType != onviftesting.ServiceImaging {
continue
}
if token, ok := op.Parameters["VideoSourceToken"].(string); ok && token != "" {
sourceOps[token] = append(sourceOps[token], op)
}
}
for token, opList := range sourceOps {
for _, op := range opList {
switch op.OperationName {
case "GetImagingSettings":
testName := fmt.Sprintf("GetImagingSettings_%s", sanitizeToken(token))
tests = append(tests, GeneratedTest{
Name: testName,
Code: fmt.Sprintf(`settings, err := client.GetImagingSettings(ctx, "%s")
if err != nil {
t.Errorf("GetImagingSettings failed: %%v", err)
return
}
t.Logf("Imaging settings retrieved for source %s")
_ = settings`, token, token),
})
case "GetOptions":
testName := fmt.Sprintf("GetImagingOptions_%s", sanitizeToken(token))
tests = append(tests, GeneratedTest{
Name: testName,
Code: fmt.Sprintf(`options, err := client.GetOptions(ctx, "%s")
if err != nil {
t.Errorf("GetOptions failed: %%v", err)
return
}
t.Logf("Imaging options retrieved for source %s")
_ = options`, token, token),
})
}
}
}
// Deduplicate
seen := make(map[string]bool)
var uniqueTests []GeneratedTest
for _, t := range tests {
if !seen[t.Name] {
seen[t.Name] = true
uniqueTests = append(uniqueTests, t)
}
}
sort.Slice(uniqueTests, func(i, j int) bool {
return uniqueTests[i].Name < uniqueTests[j].Name
})
return uniqueTests
}
func sanitizeToken(token string) string {
// Make token safe for test name
token = strings.ReplaceAll(token, "-", "_")
token = strings.ReplaceAll(token, ".", "_")
token = strings.ReplaceAll(token, " ", "_")
// Truncate if too long
if len(token) > 20 {
token = token[:20]
}
return token
}
func makeRelativePath(archivePath, outputDir string) string {
if absOutput, err := filepath.Abs(outputDir); err == nil {
if absArchive, err := filepath.Abs(archivePath); err == nil {
if rel, err := filepath.Rel(filepath.Dir(absOutput), absArchive); err == nil {
return rel
}
}
}
return archivePath
}
func extractXMLValue(xmlStr, tagName string) string {
start := fmt.Sprintf("<%s>", tagName)
end := fmt.Sprintf("</%s>", tagName)
startIdx := strings.Index(xmlStr, start)
if startIdx == -1 {
start = fmt.Sprintf(":%s>", tagName)
startIdx = strings.Index(xmlStr, start)
if startIdx == -1 {
return ""
}
startIdx += len(start)
} else {
startIdx += len(start)
}
endIdx := strings.Index(xmlStr[startIdx:], end)
if endIdx == -1 {
end = fmt.Sprintf(":/%s>", tagName)
endIdx = strings.Index(xmlStr[startIdx:], end)
if endIdx == -1 {
return ""
}
}
return strings.TrimSpace(xmlStr[startIdx : startIdx+endIdx])
}
// updateCameraRegistry updates the registry with camera information from the capture.
func updateCameraRegistry(regPath, archivePath, testFile string) {
registry, err := onviftesting.LoadRegistry(regPath)
if err != nil {
log.Printf("Warning: Failed to load registry: %v", err)
return
}
entry, err := onviftesting.CreateCameraEntryFromCapture(archivePath)
if err != nil {
log.Printf("Warning: Failed to create registry entry: %v", err)
return
}
// Set the test file path (relative to registry directory)
if testFile != "" {
regDir := filepath.Dir(regPath)
if absTest, err := filepath.Abs(testFile); err == nil {
if absRegDir, err := filepath.Abs(regDir); err == nil {
if rel, err := filepath.Rel(absRegDir, absTest); err == nil {
entry.TestFile = rel
}
}
}
if entry.TestFile == "" {
entry.TestFile = filepath.Base(testFile)
}
}
// Add or update the camera entry
registry.AddCamera(*entry)
// Update coverage statistics
updateRegistryCoverage(registry, archivePath)
// Save registry
if err := onviftesting.SaveRegistry(registry, regPath); err != nil {
log.Printf("Warning: Failed to save registry: %v", err)
return
}
fmt.Printf("✓ Registry updated: %s\n", regPath)
fmt.Printf(" Camera ID: %s\n", entry.ID)
fmt.Printf(" Total cameras in registry: %d\n", len(registry.Cameras))
}
// updateRegistryCoverage calculates coverage from captured operations.
func updateRegistryCoverage(registry *onviftesting.Registry, archivePath string) {
capture, _, err := onviftesting.LoadCaptureFromArchiveV2(archivePath)
if err != nil {
return
}
// Count unique operations per service
serviceCounts := make(map[string]map[string]bool)
for _, ex := range capture.Exchanges {
service := string(ex.ServiceType)
if service == "" || service == "Unknown" {
continue
}
if serviceCounts[service] == nil {
serviceCounts[service] = make(map[string]bool)
}
serviceCounts[service][ex.OperationName] = true
}
// Get totals from operations registry
opCounts := onviftesting.GetOperationCount()
// Update coverage
registry.Coverage = make(map[string]onviftesting.Coverage)
for service, ops := range serviceCounts {
total := 0
switch service {
case "Device":
total = opCounts.Device
case "Media":
total = opCounts.Media
case "PTZ":
total = opCounts.PTZ
case "Imaging":
total = opCounts.Imaging
case "Event":
total = opCounts.Event
case "DeviceIO":
total = opCounts.DeviceIO
}
registry.Coverage[service] = onviftesting.Coverage{
Total: total,
Captured: len(ops),
}
}
}
// generateCoverageReport generates a coverage report from the registry.
func generateCoverageReport(regPath string) {
registry, err := onviftesting.LoadRegistry(regPath)
if err != nil {
log.Fatalf("Failed to load registry: %v", err)
}
// Generate markdown report
report := generateCoverageMarkdown(registry)
// Output to file or stdout
if *coverageOutput != "" {
if err := os.WriteFile(*coverageOutput, []byte(report), 0600); err != nil { //nolint:mnd
log.Fatalf("Failed to write coverage report: %v", err)
}
fmt.Printf("✓ Coverage report written to: %s\n", *coverageOutput)
} else {
fmt.Println(report)
}
}
// generateCoverageMarkdown creates a markdown coverage report.
func generateCoverageMarkdown(registry *onviftesting.Registry) string {
var sb strings.Builder
sb.WriteString("# ONVIF Operation Coverage Report\n\n")
sb.WriteString(fmt.Sprintf("Generated: %s\n\n", time.Now().Format("2006-01-02 15:04:05")))
// Summary
sb.WriteString("## Summary\n\n")
sb.WriteString(fmt.Sprintf("- **Total Cameras**: %d\n", len(registry.Cameras)))
total, captured := registry.GetTotalCoverage()
if total > 0 {
sb.WriteString(fmt.Sprintf("- **Overall Coverage**: %.1f%% (%d/%d operations)\n\n",
float64(captured)/float64(total)*100, captured, total))
}
// Cameras
if len(registry.Cameras) > 0 {
sb.WriteString("## Registered Cameras\n\n")
sb.WriteString("| Manufacturer | Model | Firmware | Operations | Capabilities |\n")
sb.WriteString("|--------------|-------|----------|------------|---------------|\n")
for _, cam := range registry.Cameras {
caps := strings.Join(cam.Capabilities, ", ")
sb.WriteString(fmt.Sprintf("| %s | %s | %s | %d | %s |\n",
cam.Manufacturer, cam.Model, cam.Firmware, cam.OperationsCaptured, caps))
}
sb.WriteString("\n")
}
// Coverage by service
if len(registry.Coverage) > 0 {
sb.WriteString("## Coverage by Service\n\n")
sb.WriteString("| Service | Total | Captured | Coverage |\n")
sb.WriteString("|---------|-------|----------|----------|\n")
services := []string{"Device", "Media", "PTZ", "Imaging", "Event", "DeviceIO"}
for _, service := range services {
if cov, ok := registry.Coverage[service]; ok {
pct := 0.0
if cov.Total > 0 {
pct = float64(cov.Captured) / float64(cov.Total) * 100
}
sb.WriteString(fmt.Sprintf("| %s | %d | %d | %.1f%% |\n",
service, cov.Total, cov.Captured, pct))
}
}
sb.WriteString("\n")
}
// Missing operations
sb.WriteString("## Operation Specifications\n\n")
opCounts := onviftesting.GetOperationCount()
sb.WriteString(fmt.Sprintf("- Device: %d operations defined\n", opCounts.Device))
sb.WriteString(fmt.Sprintf("- Media: %d operations defined\n", opCounts.Media))
sb.WriteString(fmt.Sprintf("- PTZ: %d operations defined\n", opCounts.PTZ))
sb.WriteString(fmt.Sprintf("- Imaging: %d operations defined\n", opCounts.Imaging))
sb.WriteString(fmt.Sprintf("- Event: %d operations defined\n", opCounts.Event))
sb.WriteString(fmt.Sprintf("- DeviceIO: %d operations defined\n", opCounts.DeviceIO))
sb.WriteString(fmt.Sprintf("\n**Total**: %d read-only operations tracked\n", opCounts.Total))
return sb.String()
}
+246
View File
@@ -0,0 +1,246 @@
package main
import (
"bytes"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"strings"
)
// ASCIIConfig controls ASCII art generation parameters.
type ASCIIConfig struct {
Width int // Output width in characters
Height int // Output height in characters
Invert bool // Invert brightness
Quality string // "high", "medium", "low"
}
const (
defaultASCIIWidth = 120
defaultASCIIHeight = 40
maxColorValue = 255
bitShift8 = 8
bufferSize1024 = 1024
largeASCIIWidth = 160
largeASCIIHeight = 50
defaultQuality = "medium"
)
// DefaultASCIIConfig returns a sensible default configuration.
func DefaultASCIIConfig() ASCIIConfig {
return ASCIIConfig{
Width: defaultASCIIWidth,
Height: defaultASCIIHeight,
Invert: false,
Quality: "medium",
}
}
// ASCIICharsets define different character options.
var (
// Full charset with many shades.
charsetFull = []rune{' ', '.', ':', '-', '=', '+', '*', '#', '%', '@'}
// Medium charset - balanced.
charsetMedium = []rune{' ', '.', '-', '=', '+', '#', '@'}
// Simple charset - just a few chars.
charsetSimple = []rune{' ', '-', '#', '@'}
// Block charset - using block characters.
charsetBlock = []rune{' ', '░', '▒', '▓', '█'}
// Detailed charset.
charsetDetailed = []rune{' ', '`', '.', ',', ':', ';', '!', 'i', 'l', 'I',
'o', 'O', '0', 'e', 'E', 'p', 'P', 'x', 'X', '$', 'D', 'W', 'M', '@', '#'}
)
// ImageToASCII converts image data to ASCII art. Supports JPEG and PNG formats.
func ImageToASCII(imageData []byte, config ASCIIConfig) (string, error) {
// Decode image from bytes
img, _, err := image.Decode(bytes.NewReader(imageData))
if err != nil {
return "", fmt.Errorf("failed to decode image: %w", err)
}
return imageToASCIIFromImage(img, config, "unknown")
}
// imageToASCIIFromImage is the core conversion function.
//
//nolint:gocyclo // Image to ASCII conversion has high complexity due to multiple pixel processing paths
func imageToASCIIFromImage(img image.Image, config ASCIIConfig, format string) (string, error) { //nolint:unparam // format reserved for future use
// Validate configuration
if config.Width <= 0 {
config.Width = 120
}
if config.Height <= 0 {
config.Height = defaultASCIIHeight
}
if config.Quality == "" {
config.Quality = defaultQuality
}
// Select character set based on quality
charset := charsetMedium
switch strings.ToLower(config.Quality) {
case "high", "detailed":
charset = charsetDetailed
case "medium":
charset = charsetMedium
case "low", "simple":
charset = charsetSimple
case "block":
charset = charsetBlock
case "full":
charset = charsetFull
}
// Get image bounds
bounds := img.Bounds()
width := bounds.Max.X - bounds.Min.X
height := bounds.Max.Y - bounds.Min.Y
// Calculate scaling factors
scaleX := float64(width) / float64(config.Width)
scaleY := float64(height) / float64(config.Height)
// Build ASCII representation
var result strings.Builder
for y := 0; y < config.Height; y++ {
for x := 0; x < config.Width; x++ {
// Sample pixel from image
srcX := int(float64(x) * scaleX)
srcY := int(float64(y) * scaleY)
// Bounds check
if srcX >= width {
srcX = width - 1
}
if srcY >= height {
srcY = height - 1
}
// Get pixel color
r, g, b, _ := img.At(bounds.Min.X+srcX, bounds.Min.Y+srcY).RGBA()
// Convert to grayscale brightness (0-255)
brightness := calculateBrightness(r, g, b)
// Invert if requested
if config.Invert {
brightness = maxColorValue - brightness
}
// Map brightness to character
charIndex := int(float64(brightness) / float64(maxColorValue) * float64(len(charset)-1))
if charIndex >= len(charset) {
charIndex = len(charset) - 1
}
if charIndex < 0 {
charIndex = 0
}
result.WriteRune(charset[charIndex])
}
result.WriteRune('\n')
}
return result.String(), nil
}
// Uses standard luminance formula.
func calculateBrightness(r, g, b uint32) int {
// Convert 16-bit color to 8-bit
r8 := uint8(r >> bitShift8) //nolint:gosec // Color values are clamped to valid range
g8 := uint8(g >> bitShift8) //nolint:gosec // Color values are clamped to valid range
b8 := uint8(b >> bitShift8) //nolint:gosec // Color values are clamped to valid range
// Use standard brightness calculation
// https://en.wikipedia.org/wiki/Relative_luminance
brightness := int(0.299*float64(r8) + 0.587*float64(g8) + 0.114*float64(b8))
if brightness > maxColorValue {
brightness = maxColorValue
}
if brightness < 0 {
brightness = 0
}
return brightness
}
// FormatASCIIOutput formats ASCII art with header and footer info.
func FormatASCIIOutput(ascii string, imageInfo ImageInfo) string {
var result strings.Builder
// Header
result.WriteString("\n")
result.WriteString("╔════════════════════════════════════════════════════════════════╗\n")
result.WriteString("║ 📷 CAMERA SNAPSHOT (ASCII) ║\n")
result.WriteString("╚════════════════════════════════════════════════════════════════╝\n")
result.WriteString("\n")
// Image info
if imageInfo.Width > 0 && imageInfo.Height > 0 {
result.WriteString(fmt.Sprintf("📊 Original: %dx%d pixels\n", imageInfo.Width, imageInfo.Height))
}
if imageInfo.SizeBytes > 0 {
result.WriteString(fmt.Sprintf("💾 Size: %s\n", formatBytes(imageInfo.SizeBytes)))
}
if imageInfo.CaptureTime != "" {
result.WriteString(fmt.Sprintf("⏱️ Captured: %s\n", imageInfo.CaptureTime))
}
if imageInfo.Format != "" {
result.WriteString(fmt.Sprintf("📁 Format: %s\n", imageInfo.Format))
}
result.WriteString("\n")
// ASCII art
result.WriteString(ascii)
// Footer
result.WriteString("\n")
result.WriteString("╔════════════════════════════════════════════════════════════════╗\n")
result.WriteString("💡 Tip: Higher resolution snapshots show better detail\n")
result.WriteString("╚════════════════════════════════════════════════════════════════╝\n")
return result.String()
}
// ImageInfo holds metadata about the snapshot.
type ImageInfo struct {
Width int // Original width in pixels
Height int // Original height in pixels
SizeBytes int64 // File size in bytes
Format string // Image format (JPEG, PNG, etc)
CaptureTime string // Capture timestamp
}
// formatBytes converts bytes to human-readable format.
func formatBytes(byteCount int64) string {
if byteCount < bufferSize1024 {
return fmt.Sprintf("%d B", byteCount)
}
const kbSize = 1024
const mbSize = 1024 * 1024
if byteCount < mbSize {
return fmt.Sprintf("%.1f KB", float64(byteCount)/kbSize)
}
return fmt.Sprintf("%.1f MB", float64(byteCount)/mbSize)
}
// CreateASCIIHighQuality creates a high-quality ASCII representation.
func CreateASCIIHighQuality(imageData []byte) (string, error) {
config := ASCIIConfig{
Width: largeASCIIWidth,
Height: largeASCIIHeight,
Invert: false,
Quality: "high",
}
return ImageToASCII(imageData, config)
}
+20
View File
@@ -0,0 +1,20 @@
package main
import "errors"
var (
// ErrNoNetworkInterfaces is returned when no network interfaces are found.
ErrNoNetworkInterfaces = errors.New("no network interfaces found")
// ErrNoCamerasFound is returned when no cameras are found on any interface.
ErrNoCamerasFound = errors.New("no cameras found on any interface")
// ErrNoActiveInterfaces is returned when no active interfaces are available for discovery.
ErrNoActiveInterfaces = errors.New("no active interfaces available for discovery")
// ErrNoProfilesFound is returned when no profiles are found.
ErrNoProfilesFound = errors.New("no profiles found")
// ErrNoVideoSourceConfiguration is returned when no video source configuration is found.
ErrNoVideoSourceConfiguration = errors.New("no video source configuration found")
)
File diff suppressed because it is too large Load Diff
+365
View File
@@ -0,0 +1,365 @@
# ONVIF Camera Diagnostic Utility
A comprehensive diagnostic tool for collecting detailed information from ONVIF cameras. This utility helps analyze camera capabilities, troubleshoot issues, and generate reports for creating camera-specific tests.
## Features
**Comprehensive Testing** - Tests all major ONVIF operations:
- Device information and capabilities
- Media profiles and streaming
- Video encoder configurations
- Imaging settings
- PTZ status and presets (if available)
- System date/time
**Detailed Reporting** - Generates JSON reports with:
- All successful operations with response data
- Failed operations with error details
- Response times for performance analysis
- Structured data ready for test generation
**Easy to Use** - Simple command-line interface with minimal requirements
**XML Debugging** - For detailed debugging, see the companion `onvif-xml-capture` utility that captures raw SOAP XML
**Helpful for**:
- Creating camera-specific integration tests
- Troubleshooting ONVIF compatibility issues
- Analyzing camera capabilities
- Debugging connection problems
- Documenting camera configurations
## Installation
### Option 1: Build from source
```bash
cd /path/to/onvif-go
go build -o onvif-diagnostics ./cmd/onvif-diagnostics/
```
### Option 2: Install globally
```bash
go install ./cmd/onvif-diagnostics
```
## Usage
### Basic Usage
```bash
./onvif-diagnostics \
-endpoint "http://192.168.1.201/onvif/device_service" \
-username "service" \
-password "Service.1234"
```
### With XML Capture (for debugging)
```bash
./onvif-diagnostics \
-endpoint "http://192.168.1.201/onvif/device_service" \
-username "service" \
-password "Service.1234" \
-capture-xml \
-verbose
```
This creates two files:
- `Manufacturer_Model_Firmware_timestamp.json` - Diagnostic report
- `Manufacturer_Model_Firmware_xmlcapture_timestamp.tar.gz` - Raw SOAP XML archive
### Verbose Output
```bash
./onvif-diagnostics \
-endpoint "http://192.168.1.201/onvif/device_service" \
-username "service" \
-password "Service.1234" \
-verbose
```
### Capture Raw SOAP XML
```bash
./onvif-diagnostics \
-endpoint "http://192.168.1.201/onvif/device_service" \
-username "service" \
-password "Service.1234" \
-capture-xml
```
Enables XML traffic capture and creates a compressed tar.gz archive containing all SOAP request/response pairs. Useful for debugging XML parsing issues or analyzing camera behavior.
The archive contains:
- `capture_001_GetDeviceInformation.json` - Request/response metadata with operation name
- `capture_001_GetDeviceInformation_request.xml` - Formatted SOAP request
- `capture_001_GetDeviceInformation_response.xml` - Formatted SOAP response
- `capture_002_GetSystemDateAndTime.json` - Next operation metadata
- ... (one set per SOAP operation, named by operation type)
Each file is named with the SOAP operation (e.g., GetDeviceInformation, GetProfiles) for easy identification.
Extract the archive:
```bash
tar -xzf camera-logs/Camera_Model_xmlcapture_timestamp.tar.gz
```
### Custom Output Directory
```bash
./onvif-diagnostics \
-endpoint "http://192.168.1.201/onvif/device_service" \
-username "service" \
-password "Service.1234" \
-output ./my-camera-reports
```
### All Options
```
Usage of ./onvif-diagnostics:
-endpoint string
ONVIF device endpoint (e.g., http://192.168.1.201/onvif/device_service)
-username string
ONVIF username
-password string
ONVIF password
-output string
Output directory for logs (default "./camera-logs")
-timeout int
Request timeout in seconds (default 30)
-verbose
Verbose output
-include-raw
Include raw SOAP responses (increases file size)
```
## Example Output
```
ONVIF Camera Diagnostic Utility v1.0.0
========================================
Starting diagnostic collection...
→ 1. Getting device information...
✓ Manufacturer: Bosch, Model: FLEXIDOME indoor 5100i IR
→ 2. Getting system date and time...
✓ Retrieved
→ 3. Getting capabilities...
✓ Services: Device, Media, Imaging, Events, Analytics
→ 4. Discovering service endpoints...
✓ Service endpoints discovered
→ 5. Getting media profiles...
✓ Found 4 profile(s)
→ 6. Getting stream URIs for all profiles...
✓ Retrieved 4/4 stream URIs
→ 7. Getting snapshot URIs for all profiles...
✓ Retrieved 4/4 snapshot URIs
→ 8. Getting video encoder configurations...
✓ Retrieved 4/4 video encoder configs
→ 9. Getting imaging settings...
✓ Retrieved 1/1 imaging settings
→ 10. Getting PTZ status...
No PTZ configurations found
→ 11. Getting PTZ presets...
No PTZ configurations found
→ Saving diagnostic report...
========================================
✓ Diagnostic collection complete!
Report saved to: camera-logs/Bosch_FLEXIDOME_indoor_5100i_IR_8.71.0066_20251107-193656.json
Total errors: 0
Device: Bosch FLEXIDOME indoor 5100i IR
Firmware: 8.71.0066
Profiles: 4
Please share this file for analysis and test creation.
========================================
```
## Report Structure
The generated JSON report includes:
```json
{
"timestamp": "2025-11-07T19:36:56Z",
"utility_version": "1.0.0",
"connection_info": {
"endpoint": "http://192.168.1.201/onvif/device_service",
"username": "service",
"test_date": "2025-11-07"
},
"device_info": {
"success": true,
"data": {
"manufacturer": "Bosch",
"model": "FLEXIDOME indoor 5100i IR",
"firmware_version": "8.71.0066",
"serial_number": "404754734001050102",
"hardware_id": "F000B543"
},
"response_time": "21.5ms"
},
"profiles": {
"success": true,
"count": 4,
"data": [ /* profile details */ ]
},
"stream_uris": [ /* stream URI results for each profile */ ],
"errors": [ /* any errors encountered */ ]
}
```
## Use Cases
### 1. Creating Camera-Specific Tests
Run the diagnostic on your camera and share the JSON file. The report contains all the information needed to create comprehensive integration tests.
### 2. Troubleshooting Connection Issues
If your camera isn't working, run diagnostics to see exactly which operations fail and what error messages are returned.
### 3. Comparing Cameras
Run diagnostics on multiple cameras to compare capabilities, response times, and compatibility.
### 4. Documentation
Generate detailed reports of camera configurations for documentation purposes.
## Interpreting Results
### Success Indicators
- ✓ Green checkmarks indicate successful operations
- Response times help identify performance issues
- High success rates indicate good compatibility
### Error Indicators
- ✗ Red X marks indicate failed operations
- Info symbols indicate optional features not available
- Check the `errors` array in JSON for detailed error messages
### Common Issues
**All operations fail:**
- Check network connectivity
- Verify endpoint URL is correct
- Ensure camera is powered on
**Authentication errors:**
- Verify username and password
- Check user permissions on camera
**Some profiles fail:**
- Camera may have different capabilities per profile
- Some operations may not be supported by all profiles
**Timeout errors:**
- Increase timeout with `-timeout 60`
- Check network latency
- Verify camera is responding
## Sharing Reports
When sharing diagnostic reports:
1. **Anonymize if needed** - The report includes:
- IP addresses (in endpoint)
- Usernames (not passwords)
- Serial numbers
2. **What to share**:
- The complete JSON file
- Any console output showing errors
- Camera model and firmware version
3. **Where to share**:
- GitHub Issues
- Email for analysis
- Pull request descriptions
## Advanced Usage
### Batch Testing Multiple Cameras
Create a script to test multiple cameras:
```bash
#!/bin/bash
cameras=(
"192.168.1.201:service:password1"
"192.168.1.202:admin:password2"
"192.168.1.203:user:password3"
)
for camera in "${cameras[@]}"; do
IFS=':' read -r ip user pass <<< "$camera"
echo "Testing camera at $ip..."
./onvif-diagnostics \
-endpoint "http://$ip/onvif/device_service" \
-username "$user" \
-password "$pass"
done
```
### Automated Testing
Include in CI/CD pipelines:
```yaml
- name: Run ONVIF Diagnostics
run: |
./onvif-diagnostics \
-endpoint "${{ secrets.CAMERA_ENDPOINT }}" \
-username "${{ secrets.CAMERA_USERNAME }}" \
-password "${{ secrets.CAMERA_PASSWORD }}" \
-output ./reports
- name: Upload Diagnostic Reports
uses: actions/upload-artifact@v3
with:
name: camera-diagnostics
path: ./reports/
```
## Development
### Adding New Tests
To add new diagnostic tests, edit `cmd/onvif-diagnostics/main.go`:
1. Create a new test function following the pattern:
```go
func testNewOperation(ctx context.Context, client *onvif.Client, report *CameraReport) *NewOperationResult {
// Implementation
}
```
2. Add result struct to store data
3. Call the test in main()
4. Update report structure
### Building for Different Platforms
```bash
# Linux
GOOS=linux GOARCH=amd64 go build -o onvif-diagnostics-linux ./cmd/onvif-diagnostics/
# Windows
GOOS=windows GOARCH=amd64 go build -o onvif-diagnostics.exe ./cmd/onvif-diagnostics/
# macOS ARM
GOOS=darwin GOARCH=arm64 go build -o onvif-diagnostics-mac-arm ./cmd/onvif-diagnostics/
```
## License
Same as parent project.
## Support
For issues or questions:
1. Run diagnostics with `-verbose` flag
2. Share the generated JSON report
3. **For XML parsing issues**: Use `onvif-xml-capture` utility to capture raw SOAP XML
4. Open a GitHub issue with the report attached
## Related Tools
- **onvif-xml-capture** - Captures raw SOAP XML requests/responses for detailed debugging
- Location: `cmd/onvif-xml-capture/`
- Use when: Diagnostic report shows errors and you need to see raw XML
- See: `XML_DEBUGGING_SOLUTION.md` for complete guide
File diff suppressed because it is too large Load Diff
+442
View File
@@ -0,0 +1,442 @@
package main
import (
"bufio"
"context"
"fmt"
"os"
"strings"
"time"
"github.com/0x524a/onvif-go"
"github.com/0x524a/onvif-go/discovery"
)
const (
defaultUsername = "admin"
defaultTimeout = 10
defaultRetryDelay = 5
ptzTimeout = 30
ptzStepSize = 2
ptzSpeed = 0.5
maxBodyPreview = 200
)
func main() {
reader := bufio.NewReader(os.Stdin)
fmt.Println("🎥 Quick ONVIF Camera Tool")
fmt.Println("==========================")
fmt.Println()
for {
fmt.Println("What would you like to do?")
fmt.Println("1. 🔍 Discover cameras")
fmt.Println("2. 🌐 List network interfaces")
fmt.Println("3. 📹 Connect to camera")
fmt.Println("4. 🎮 PTZ demo")
fmt.Println("5. 📡 Get stream URLs")
fmt.Println("0. Exit")
fmt.Print("\nChoice: ")
//nolint:errcheck // ReadString error on stdin is rare and not critical for CLI
input, _ := reader.ReadString('\n')
choice := strings.TrimSpace(input)
switch choice {
case "1":
discoverCameras()
case "2":
listNetworkInterfaces()
case "3":
connectAndShowInfo()
case "4":
ptzDemo()
case "5":
getStreamURLs()
case "0", "q", "quit":
fmt.Println("Goodbye! 👋")
return
default:
fmt.Println("Invalid choice. Please try again.")
}
fmt.Println()
}
}
func discoverCameras() {
reader := bufio.NewReader(os.Stdin)
fmt.Println("🔍 Discovering cameras on network...")
// Ask if user wants to use a specific interface
fmt.Print("Use specific network interface? (y/n) [n]: ")
//nolint:errcheck // ReadString error on stdin is rare and not critical for CLI
useInterface, _ := reader.ReadString('\n')
useInterface = strings.ToLower(strings.TrimSpace(useInterface))
var opts *discovery.DiscoverOptions
if useInterface == "y" || useInterface == "yes" {
// List interfaces
interfaces, err := discovery.ListNetworkInterfaces()
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Println("\nAvailable interfaces:")
for i, iface := range interfaces {
fmt.Printf(" %d. %s (%v)\n", i+1, iface.Name, iface.Addresses)
}
fmt.Print("\nEnter interface name or IP: ")
//nolint:errcheck // ReadString error on stdin is rare and not critical for CLI
ifaceInput, _ := reader.ReadString('\n')
ifaceInput = strings.TrimSpace(ifaceInput)
if ifaceInput != "" {
opts = &discovery.DiscoverOptions{
NetworkInterface: ifaceInput,
}
}
}
if opts == nil {
opts = &discovery.DiscoverOptions{}
}
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout*time.Second)
defer cancel()
devices, err := discovery.DiscoverWithOptions(ctx, defaultRetryDelay*time.Second, opts)
if err != nil {
fmt.Printf("❌ Error: %v\n", err)
return
}
if len(devices) == 0 {
fmt.Println("No cameras found")
return
}
fmt.Printf("✅ Found %d camera(s):\n", len(devices))
for i, device := range devices {
fmt.Printf(" %d. %s (%s)\n", i+1, device.GetName(), device.GetDeviceEndpoint())
}
}
func listNetworkInterfaces() {
fmt.Println("🌐 Network Interfaces")
fmt.Println("====================")
interfaces, err := discovery.ListNetworkInterfaces()
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
if len(interfaces) == 0 {
fmt.Println("No network interfaces found")
return
}
fmt.Printf("✅ Found %d interface(s):\n\n", len(interfaces))
for _, iface := range interfaces {
upStr := "Up"
if !iface.Up {
upStr = "Down"
}
multicastStr := "Yes"
if !iface.Multicast {
multicastStr = "No"
}
fmt.Printf("📡 %s (%s, Multicast: %s)\n", iface.Name, upStr, multicastStr)
if len(iface.Addresses) > 0 {
for _, addr := range iface.Addresses {
fmt.Printf(" └─ %s\n", addr)
}
}
}
}
func connectAndShowInfo() {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Camera IP: ")
//nolint:errcheck // ReadString error on stdin is rare and not critical for CLI
ip, _ := reader.ReadString('\n')
ip = strings.TrimSpace(ip)
fmt.Print("Username [admin]: ")
//nolint:errcheck // ReadString error on stdin is rare and not critical for CLI
username, _ := reader.ReadString('\n')
username = strings.TrimSpace(username)
if username == "" {
username = defaultUsername
}
fmt.Print("Password: ")
//nolint:errcheck // ReadString error on stdin is rare and not critical for CLI
password, _ := reader.ReadString('\n')
password = strings.TrimSpace(password)
endpoint := fmt.Sprintf("http://%s/onvif/device_service", ip)
fmt.Printf("Connecting to %s...\n", endpoint)
client, err := onvif.NewClient(
endpoint,
onvif.WithCredentials(username, password),
onvif.WithTimeout(ptzTimeout*time.Second),
)
if err != nil {
fmt.Printf("❌ Error: %v\n", err)
return
}
ctx := context.Background()
// Get device info
info, err := client.GetDeviceInformation(ctx)
if err != nil {
fmt.Printf("❌ Connection failed: %v\n", err)
return
}
fmt.Printf("✅ Connected!\n")
fmt.Printf("📹 %s %s\n", info.Manufacturer, info.Model)
fmt.Printf("🔧 Firmware: %s\n", info.FirmwareVersion)
// Initialize and get profiles
//nolint:errcheck // Ignore initialization errors, we'll catch them on GetProfiles
_ = client.Initialize(ctx)
profiles, err := client.GetProfiles(ctx)
if err == nil && len(profiles) > 0 {
fmt.Printf("📺 %d profile(s) available\n", len(profiles))
// Show first stream URL
streamURI, err := client.GetStreamURI(ctx, profiles[0].Token)
if err == nil {
fmt.Printf("📡 Stream: %s\n", streamURI.URI)
}
}
}
func ptzDemo() { //nolint:funlen,gocyclo // Many statements and high complexity due to user interaction
reader := bufio.NewReader(os.Stdin)
fmt.Print("Camera IP: ")
//nolint:errcheck // ReadString error on stdin is rare and not critical for CLI
ip, _ := reader.ReadString('\n')
ip = strings.TrimSpace(ip)
fmt.Print("Username [admin]: ")
//nolint:errcheck // ReadString error on stdin is rare and not critical for CLI
username, _ := reader.ReadString('\n')
username = strings.TrimSpace(username)
if username == "" {
username = defaultUsername
}
fmt.Print("Password: ")
//nolint:errcheck // ReadString error on stdin is rare and not critical for CLI
password, _ := reader.ReadString('\n')
password = strings.TrimSpace(password)
endpoint := fmt.Sprintf("http://%s/onvif/device_service", ip)
client, err := onvif.NewClient(
endpoint,
onvif.WithCredentials(username, password),
)
if err != nil {
fmt.Printf("❌ Error: %v\n", err)
return
}
ctx := context.Background()
//nolint:errcheck // Ignore initialization errors, we'll catch them on GetProfiles
_ = client.Initialize(ctx)
profiles, err := client.GetProfiles(ctx)
if err != nil || len(profiles) == 0 {
fmt.Println("❌ No profiles found")
return
}
profileToken := profiles[0].Token
// Check PTZ status
status, err := client.GetStatus(ctx, profileToken)
if err != nil {
fmt.Printf("❌ PTZ not supported: %v\n", err)
return
}
fmt.Println("✅ PTZ is supported!")
if status.Position != nil && status.Position.PanTilt != nil {
fmt.Printf("Current position: Pan=%.2f, Tilt=%.2f\n",
status.Position.PanTilt.X, status.Position.PanTilt.Y)
}
fmt.Println("\n🎮 PTZ Demo - Choose movement:")
fmt.Println("1. Move right")
fmt.Println("2. Move left")
fmt.Println("3. Move up")
fmt.Println("4. Move down")
fmt.Println("5. Go to center")
fmt.Print("Choice: ")
//nolint:errcheck // ReadString error on stdin is rare and not critical for CLI
choice, _ := reader.ReadString('\n')
choice = strings.TrimSpace(choice)
var velocity *onvif.PTZSpeed
var position *onvif.PTZVector
switch choice {
case "1":
velocity = &onvif.PTZSpeed{PanTilt: &onvif.Vector2D{X: ptzSpeed, Y: 0.0}}
case "2":
velocity = &onvif.PTZSpeed{PanTilt: &onvif.Vector2D{X: -ptzSpeed, Y: 0.0}}
case "3":
velocity = &onvif.PTZSpeed{PanTilt: &onvif.Vector2D{X: 0.0, Y: ptzSpeed}}
case "4":
velocity = &onvif.PTZSpeed{PanTilt: &onvif.Vector2D{X: 0.0, Y: -ptzSpeed}}
case "5":
position = &onvif.PTZVector{PanTilt: &onvif.Vector2D{X: 0.0, Y: 0.0}}
default:
fmt.Println("Invalid choice")
return
}
if velocity != nil {
timeout := fmt.Sprintf("PT%dS", ptzStepSize)
err = client.ContinuousMove(ctx, profileToken, velocity, &timeout)
if err != nil {
fmt.Printf("❌ Error: %v\n", err)
return
}
fmt.Println("✅ Moving for 2 seconds...")
time.Sleep(ptzStepSize * time.Second)
//nolint:errcheck // Stop error is not critical for demo
_ = client.Stop(ctx, profileToken, true, false)
} else if position != nil {
err = client.AbsoluteMove(ctx, profileToken, position, nil)
if err != nil {
fmt.Printf("❌ Error: %v\n", err)
return
}
fmt.Println("✅ Moving to center...")
}
fmt.Println("Demo complete!")
}
func getStreamURLs() {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Camera IP: ")
//nolint:errcheck // ReadString error on stdin is rare and not critical for CLI
ip, _ := reader.ReadString('\n')
ip = strings.TrimSpace(ip)
fmt.Print("Username [admin]: ")
//nolint:errcheck // ReadString error on stdin is rare and not critical for CLI
username, _ := reader.ReadString('\n')
username = strings.TrimSpace(username)
if username == "" {
username = defaultUsername
}
fmt.Print("Password: ")
//nolint:errcheck // ReadString error on stdin is rare and not critical for CLI
password, _ := reader.ReadString('\n')
password = strings.TrimSpace(password)
endpoint := fmt.Sprintf("http://%s/onvif/device_service", ip)
client, err := onvif.NewClient(
endpoint,
onvif.WithCredentials(username, password),
)
if err != nil {
fmt.Printf("❌ Error: %v\n", err)
return
}
ctx := context.Background()
//nolint:errcheck // Ignore initialization errors, we'll catch them on GetProfiles
_ = client.Initialize(ctx)
profiles, err := client.GetProfiles(ctx)
if err != nil {
fmt.Printf("❌ Error: %v\n", err)
return
}
if len(profiles) == 0 {
fmt.Println("❌ No profiles found")
return
}
fmt.Printf("✅ Found %d profile(s):\n\n", len(profiles))
for i, profile := range profiles {
fmt.Printf("📹 Profile %d: %s\n", i+1, profile.Name)
// Stream URI
streamURI, err := client.GetStreamURI(ctx, profile.Token)
if err != nil {
fmt.Printf(" Stream: ❌ Error\n")
} else {
fmt.Printf(" 📡 Stream: %s\n", streamURI.URI)
}
// Snapshot URI
snapshotURI, err := client.GetSnapshotURI(ctx, profile.Token)
if err != nil {
fmt.Printf(" Snapshot: ❌ Error\n")
} else {
fmt.Printf(" 📸 Snapshot: %s\n", snapshotURI.URI)
}
// Video info
if profile.VideoEncoderConfiguration != nil {
fmt.Printf(" 🎬 Encoding: %s", profile.VideoEncoderConfiguration.Encoding)
if profile.VideoEncoderConfiguration.Resolution != nil {
fmt.Printf(" (%dx%d)",
profile.VideoEncoderConfiguration.Resolution.Width,
profile.VideoEncoderConfiguration.Resolution.Height)
}
fmt.Println()
}
fmt.Println()
}
fmt.Println("💡 Tips:")
fmt.Println(" - Use VLC to open RTSP streams")
fmt.Println(" - Open snapshot URLs in a web browser")
fmt.Println(" - Some cameras may require authentication in the URL")
}
+245
View File
@@ -0,0 +1,245 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/0x524a/onvif-go/server"
)
var (
version = "1.0.0"
)
const (
defaultPort = 8080
maxWorkers = 3
defaultTimeout = 30
ptzStepSize = 5
ptzMaxPan = 180
ptzMaxTilt = 90
ptzSpeed = 0.5
)
func main() {
// Define command-line flags
host := flag.String("host", "0.0.0.0", "Server host address")
port := flag.Int("port", defaultPort, "Server port")
username := flag.String("username", "admin", "Authentication username")
password := flag.String("password", "admin", "Authentication password")
manufacturer := flag.String("manufacturer", "onvif-go", "Device manufacturer")
model := flag.String("model", "Virtual Multi-Lens Camera", "Device model")
firmware := flag.String("firmware", "1.0.0", "Firmware version")
serial := flag.String("serial", "SN-12345678", "Serial number")
profiles := flag.Int(
"profiles", maxWorkers, "Number of camera profiles (1-10)",
)
ptz := flag.Bool("ptz", true, "Enable PTZ support")
imaging := flag.Bool("imaging", true, "Enable Imaging support")
events := flag.Bool("events", false, "Enable Events support")
info := flag.Bool("info", false, "Show server info and exit")
showVersion := flag.Bool("version", false, "Show version and exit")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "ONVIF Server - Virtual IP Camera Simulator\n\n")
fmt.Fprintf(os.Stderr, "Usage: %s [options]\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Options:\n")
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nExamples:\n")
fmt.Fprintf(os.Stderr, " # Start with default settings (3 profiles, PTZ enabled)\n")
fmt.Fprintf(os.Stderr, " %s\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, " # Start with custom credentials and 5 profiles\n")
fmt.Fprintf(os.Stderr, " %s -username myuser -password mypass -profiles 5\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, " # Start on specific port without PTZ\n")
fmt.Fprintf(os.Stderr, " %s -port 9000 -ptz=false\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, " # Show server information\n")
fmt.Fprintf(os.Stderr, " %s -info\n\n", os.Args[0])
}
flag.Parse()
// Handle version flag
if *showVersion {
fmt.Printf("onvif-server version %s\n", version)
os.Exit(0)
}
// Validate profiles count
if *profiles < 1 || *profiles > 10 {
log.Fatal("Number of profiles must be between 1 and 10")
}
// Create server configuration
config := buildConfig(*host, *port, *username, *password, *manufacturer, *model,
*firmware, *serial, *profiles, *ptz, *imaging, *events)
// Create server
srv, err := server.New(config)
if err != nil {
log.Fatalf("Failed to create server: %v", err)
}
// Handle info flag
if *info {
fmt.Println(srv.ServerInfo())
os.Exit(0)
}
// Print banner
printBanner()
// Create context that listens for interrupt signals
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Setup signal handler
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
// Start server in goroutine
go func() {
if err := srv.Start(ctx); err != nil {
log.Printf("Server error: %v", err)
cancel()
}
}()
// Wait for interrupt signal
<-sigChan
fmt.Println("\n🛑 Received interrupt signal, shutting down...")
cancel()
// Give the server a moment to shut down gracefully
time.Sleep(1 * time.Second)
fmt.Println("✅ Server stopped")
}
// buildConfig creates a server configuration from command-line arguments.
func buildConfig(host string, port int, username, password, manufacturer, model,
firmware, serial string, numProfiles int, ptz, imaging, events bool) *server.Config {
config := &server.Config{
Host: host,
Port: port,
BasePath: "/onvif",
Timeout: defaultTimeout * time.Second,
DeviceInfo: server.DeviceInfo{
Manufacturer: manufacturer,
Model: model,
FirmwareVersion: firmware,
SerialNumber: serial,
HardwareID: "HW-87654321",
},
Username: username,
Password: password,
SupportPTZ: ptz,
SupportImaging: imaging,
SupportEvents: events,
Profiles: make([]server.ProfileConfig, numProfiles),
}
// Define profile templates
templates := []struct {
name string
width int
height int
framerate int
bitrate int
quality float64
hasPTZ bool
ptzZoomMax float64
}{
{"Main Camera - High Quality", 1920, 1080, 30, 4096, 80, true, 1},
{"Wide Angle Camera", 1280, 720, 30, 2048, 75, false, 0},
{"Telephoto Camera", 1920, 1080, 25, 6144, 85, true, 3},
{"Low Light Camera", 1920, 1080, 30, 4096, 80, false, 0},
{"Ultra HD Camera", 3840, 2160, 30, 16384, 90, true, 2},
{"Compact Camera", 640, 480, 30, 512, 70, false, 0},
{"PTZ Dome Camera", 1920, 1080, 30, 4096, 80, true, 2},
{"Fisheye Camera", 1920, 1080, 30, 4096, 80, false, 0},
{"Thermal Camera", 640, 480, 30, 1024, 75, true, 1},
{"License Plate Camera", 1920, 1080, 60, 8192, 90, true, 5},
}
// Generate profiles
for i := 0; i < numProfiles; i++ {
template := templates[i%len(templates)]
profile := server.ProfileConfig{
Token: fmt.Sprintf("profile_%d", i),
Name: template.name,
VideoSource: server.VideoSourceConfig{
Token: fmt.Sprintf("video_source_%d", i),
Name: template.name,
Resolution: server.Resolution{Width: template.width, Height: template.height},
Framerate: template.framerate,
Bounds: server.Bounds{X: 0, Y: 0, Width: template.width, Height: template.height},
},
VideoEncoder: server.VideoEncoderConfig{
Encoding: "H264",
Resolution: server.Resolution{Width: template.width, Height: template.height},
Quality: template.quality,
Framerate: template.framerate,
Bitrate: template.bitrate,
GovLength: template.framerate,
},
Snapshot: server.SnapshotConfig{
Enabled: true,
Resolution: server.Resolution{Width: template.width, Height: template.height},
Quality: template.quality + 5, //nolint:mnd // Quality offset
},
}
// Add PTZ if enabled and template supports it
if ptz && template.hasPTZ {
profile.PTZ = &server.PTZConfig{
NodeToken: fmt.Sprintf("ptz_node_%d", i),
PanRange: server.Range{Min: -ptzMaxPan, Max: ptzMaxPan},
TiltRange: server.Range{Min: -ptzMaxTilt, Max: ptzMaxTilt},
ZoomRange: server.Range{Min: 0, Max: template.ptzZoomMax},
DefaultSpeed: server.PTZSpeed{Pan: ptzSpeed, Tilt: ptzSpeed, Zoom: ptzSpeed},
SupportsContinuous: true,
SupportsAbsolute: true,
SupportsRelative: true,
Presets: []server.Preset{
{
Token: fmt.Sprintf("preset_%d_0", i),
Name: "Home",
Position: server.PTZPosition{Pan: 0, Tilt: 0, Zoom: 0},
},
{
Token: fmt.Sprintf("preset_%d_1", i),
Name: "Entrance",
Position: server.PTZPosition{
Pan: -45, Tilt: -10, Zoom: template.ptzZoomMax * ptzSpeed,
},
},
},
}
}
config.Profiles[i] = profile
}
return config
}
// printBanner prints the application banner.
func printBanner() {
banner := `
🎥 ONVIF Virtual Camera Server 🎥
Simulate multi-lens IP cameras with ONVIF support
Version: ` + version + `
`
fmt.Println(banner)
}