This commit is contained in:
ProtoTess
2026-01-16 04:58:24 +00:00
parent 19db372cdc
commit 9cf30e2c41
541 changed files with 1880 additions and 185062 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()
}
}
+758 -101
View File
@@ -6,8 +6,10 @@ import (
"log"
"os"
"path/filepath"
"sort"
"strings"
"text/template"
"time"
onviftesting "github.com/0x524a/onvif-go/testing"
)
@@ -16,6 +18,10 @@ 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}}
@@ -29,12 +35,14 @@ import (
onviftesting "github.com/0x524a/onvif-go/testing"
)
// Test{{.CameraName}} tests ONVIF client against {{.CameraDescription}} captured responses
// 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.NewMockSOAPServer(captureArchive)
mockServer, err := onviftesting.NewMockSOAPServerV2(captureArchive)
if err != nil {
t.Fatalf("Failed to create mock server: %v", err)
}
@@ -52,69 +60,48 @@ func Test{{.CameraName}}(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
t.Run("GetDeviceInformation", func(t *testing.T) {
info, err := client.GetDeviceInformation(ctx)
if err != nil {
t.Errorf("GetDeviceInformation failed: %v", err)
return
}
// Validate expected values
if info.Manufacturer == "" {
t.Error("Manufacturer is empty")
}
if info.Model == "" {
t.Error("Model is empty")
}
if info.FirmwareVersion == "" {
t.Error("FirmwareVersion is empty")
}
t.Logf("Device: %s %s (Firmware: %s)", info.Manufacturer, info.Model, info.FirmwareVersion)
// =========================================================================
// Device Service Operations
// =========================================================================
{{range .DeviceTests}}
t.Run("{{.Name}}", func(t *testing.T) {
{{.Code}}
})
t.Run("GetSystemDateAndTime", func(t *testing.T) {
_, err := client.GetSystemDateAndTime(ctx)
if err != nil {
t.Errorf("GetSystemDateAndTime failed: %v", err)
}
{{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}}
})
t.Run("GetCapabilities", func(t *testing.T) {
caps, err := client.GetCapabilities(ctx)
if err != nil {
t.Errorf("GetCapabilities failed: %v", err)
return
}
if caps.Device == nil {
t.Error("Device capabilities is nil")
}
if caps.Media == nil {
t.Error("Media capabilities is nil")
}
t.Logf("Capabilities: Device=%v, Media=%v, Imaging=%v, PTZ=%v",
caps.Device != nil, caps.Media != nil, caps.Imaging != nil, caps.PTZ != nil)
{{end}}
// =========================================================================
// Profile-Dependent Operations
// =========================================================================
{{range .ProfileTests}}
t.Run("{{.Name}}", func(t *testing.T) {
{{.Code}}
})
t.Run("GetProfiles", func(t *testing.T) {
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))
for i, profile := range profiles {
t.Logf(" Profile %d: %s (Token: %s)", i+1, profile.Name, profile.Token)
}
{{end}}
// =========================================================================
// PTZ Operations
// =========================================================================
{{range .PTZTests}}
t.Run("{{.Name}}", func(t *testing.T) {
{{.Code}}
})
{{range .AdditionalTests}}
{{end}}
// =========================================================================
// Imaging Operations
// =========================================================================
{{range .ImagingTests}}
t.Run("{{.Name}}", func(t *testing.T) {
{{.Code}}
})
@@ -127,17 +114,43 @@ type TestData struct {
CameraName string
CameraDescription string
CaptureArchiveRelPath string
AdditionalTests []AdditionalTest
TotalExchanges int
NeedsInit bool
DeviceTests []GeneratedTest
MediaTests []GeneratedTest
ProfileTests []GeneratedTest
PTZTests []GeneratedTest
ImagingTests []GeneratedTest
}
type AdditionalTest struct {
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()
@@ -146,18 +159,29 @@ func main() {
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)
}
// Load capture to get camera info
capture, err := onviftesting.LoadCaptureFromArchive(*captureArchive)
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)
// Remove _xmlcapture_timestamp.tar.gz suffix
parts := strings.Split(baseName, "_xmlcapture_")
cameraID := parts[0]
@@ -166,45 +190,44 @@ func main() {
cameraName = strings.ReplaceAll(cameraName, ".", "")
cameraName = strings.ReplaceAll(cameraName, " ", "")
// Get device info from first exchange (GetDeviceInformation)
// Get camera description from metadata or extract from captures
cameraDesc := cameraID
if len(capture.Exchanges) > 0 {
// Try to parse device info from response
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 !strings.Contains(ex.RequestBody, "GetDeviceInformation") {
continue
}
// Extract manufacturer and model from response
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
}
}
// Prepare test data
// Make archive path relative if inside output directory
relArchivePath := *captureArchive
// If archive is in a sibling directory to output, make it relative
if absOutput, err := filepath.Abs(*outputDir); err == nil {
if absArchive, err := filepath.Abs(*captureArchive); err == nil {
if rel, err := filepath.Rel(filepath.Dir(absOutput), absArchive); err == nil {
relArchivePath = rel
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: relArchivePath,
AdditionalTests: []AdditionalTest{},
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
@@ -213,7 +236,6 @@ func main() {
log.Fatalf("Failed to parse template: %v", err)
}
// Create output file
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 {
@@ -225,26 +247,481 @@ func main() {
if err := tmpl.Execute(f, testData); err != nil {
_ = f.Close()
//nolint:gocritic // Fatalf exits, defer won't run - this is acceptable
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 {
// Simple extraction for basic tags
start := fmt.Sprintf("<%s>", tagName)
end := fmt.Sprintf("</%s>", tagName)
startIdx := strings.Index(xmlStr, start)
if startIdx == -1 {
// Try with namespace prefix
start = fmt.Sprintf(":%s>", tagName)
startIdx = strings.Index(xmlStr, start)
if startIdx == -1 {
@@ -257,7 +734,6 @@ func extractXMLValue(xmlStr, tagName string) string {
endIdx := strings.Index(xmlStr[startIdx:], end)
if endIdx == -1 {
// Try with namespace prefix
end = fmt.Sprintf(":/%s>", tagName)
endIdx = strings.Index(xmlStr[startIdx:], end)
if endIdx == -1 {
@@ -267,3 +743,184 @@ func extractXMLValue(xmlStr, tagName string) string {
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()
}
+665 -95
View File
@@ -14,10 +14,13 @@ import (
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/0x524a/onvif-go"
onviftesting "github.com/0x524a/onvif-go/testing"
)
const (
@@ -150,6 +153,7 @@ var (
timeout = flag.Int("timeout", 30, "Request timeout in seconds") //nolint:mnd // Default timeout value
verbose = flag.Bool("verbose", false, "Verbose output")
captureXML = flag.Bool("capture-xml", false, "Capture raw SOAP XML traffic and create tar.gz archive")
captureAll = flag.Bool("capture-all", false, "Capture all READ operations (comprehensive mode, implies -capture-xml)")
)
//nolint:funlen,gocognit,gocyclo // Main function has high complexity due to multiple diagnostic operations
@@ -192,6 +196,11 @@ func main() {
RawResponses: make(map[string]interface{}),
}
// If capture-all is set, enable capture-xml automatically
if *captureAll {
*captureXML = true
}
// Setup XML capture if requested
var loggingTransport *LoggingTransport
var xmlCaptureDir string
@@ -246,72 +255,79 @@ func main() {
ctx := context.Background()
fmt.Println("Starting diagnostic collection...")
fmt.Println()
// Test 1: Get Device Information
logStepf("1. Getting device information...")
report.DeviceInfo = testGetDeviceInformation(ctx, client, report)
// Test 2: Get System Date and Time
logStepf("2. Getting system date and time...")
report.SystemDateTime = testGetSystemDateTime(ctx, client, report)
// Test 3: Get Capabilities
logStepf("3. Getting capabilities...")
report.Capabilities = testGetCapabilities(ctx, client, report)
// Test 4: Initialize (discover services)
logStepf("4. Discovering service endpoints...")
if err := client.Initialize(ctx); err != nil {
logErrorf("Service discovery failed: %v", err)
report.Errors = append(report.Errors, ErrorLog{
Operation: "Initialize",
Error: err.Error(),
Timestamp: time.Now().Format(time.RFC3339),
})
if *captureAll {
fmt.Println("Starting COMPREHENSIVE diagnostic collection...")
fmt.Println("This will capture all READ operations for testing.")
fmt.Println()
runComprehensiveCapture(ctx, client, report)
} else {
logSuccessf("Service endpoints discovered")
}
fmt.Println("Starting diagnostic collection...")
fmt.Println()
// Test 5: Get Profiles
logStepf("5. Getting media profiles...")
report.Profiles = testGetProfiles(ctx, client, report)
// Test 1: Get Device Information
logStepf("1. Getting device information...")
report.DeviceInfo = testGetDeviceInformation(ctx, client, report)
// Test 6: Get Stream URIs (for each profile)
if report.Profiles != nil && report.Profiles.Success {
logStepf("6. Getting stream URIs for all profiles...")
report.StreamURIs = testGetStreamURIs(ctx, client, report.Profiles.Data, report)
}
// Test 2: Get System Date and Time
logStepf("2. Getting system date and time...")
report.SystemDateTime = testGetSystemDateTime(ctx, client, report)
// Test 7: Get Snapshot URIs (for each profile)
if report.Profiles != nil && report.Profiles.Success {
logStepf("7. Getting snapshot URIs for all profiles...")
report.SnapshotURIs = testGetSnapshotURIs(ctx, client, report.Profiles.Data, report)
}
// Test 3: Get Capabilities
logStepf("3. Getting capabilities...")
report.Capabilities = testGetCapabilities(ctx, client, report)
// Test 8: Get Video Encoder Configurations
if report.Profiles != nil && report.Profiles.Success {
logStepf("8. Getting video encoder configurations...")
report.VideoEncoders = testGetVideoEncoders(ctx, client, report.Profiles.Data, report)
}
// Test 4: Initialize (discover services)
logStepf("4. Discovering service endpoints...")
if err := client.Initialize(ctx); err != nil {
logErrorf("Service discovery failed: %v", err)
report.Errors = append(report.Errors, ErrorLog{
Operation: "Initialize",
Error: err.Error(),
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
logSuccessf("Service endpoints discovered")
}
// Test 9: Get Imaging Settings
if report.Profiles != nil && report.Profiles.Success {
logStepf("9. Getting imaging settings...")
report.ImagingSettings = testGetImagingSettings(ctx, client, report.Profiles.Data, report)
}
// Test 5: Get Profiles
logStepf("5. Getting media profiles...")
report.Profiles = testGetProfiles(ctx, client, report)
// Test 10: Get PTZ Status (if PTZ is available)
if report.Profiles != nil && report.Profiles.Success {
logStepf("10. Getting PTZ status...")
report.PTZStatus = testGetPTZStatus(ctx, client, report.Profiles.Data, report)
}
// Test 6: Get Stream URIs (for each profile)
if report.Profiles != nil && report.Profiles.Success {
logStepf("6. Getting stream URIs for all profiles...")
report.StreamURIs = testGetStreamURIs(ctx, client, report.Profiles.Data, report)
}
// Test 11: Get PTZ Presets (if PTZ is available)
if report.Profiles != nil && report.Profiles.Success {
logStepf("11. Getting PTZ presets...")
report.PTZPresets = testGetPTZPresets(ctx, client, report.Profiles.Data, report)
// Test 7: Get Snapshot URIs (for each profile)
if report.Profiles != nil && report.Profiles.Success {
logStepf("7. Getting snapshot URIs for all profiles...")
report.SnapshotURIs = testGetSnapshotURIs(ctx, client, report.Profiles.Data, report)
}
// Test 8: Get Video Encoder Configurations
if report.Profiles != nil && report.Profiles.Success {
logStepf("8. Getting video encoder configurations...")
report.VideoEncoders = testGetVideoEncoders(ctx, client, report.Profiles.Data, report)
}
// Test 9: Get Imaging Settings
if report.Profiles != nil && report.Profiles.Success {
logStepf("9. Getting imaging settings...")
report.ImagingSettings = testGetImagingSettings(ctx, client, report.Profiles.Data, report)
}
// Test 10: Get PTZ Status (if PTZ is available)
if report.Profiles != nil && report.Profiles.Success {
logStepf("10. Getting PTZ status...")
report.PTZStatus = testGetPTZStatus(ctx, client, report.Profiles.Data, report)
}
// Test 11: Get PTZ Presets (if PTZ is available)
if report.Profiles != nil && report.Profiles.Success {
logStepf("11. Getting PTZ presets...")
report.PTZPresets = testGetPTZPresets(ctx, client, report.Profiles.Data, report)
}
}
// Generate output filename based on device info
@@ -327,7 +343,14 @@ func main() {
// Create XML archive if capture was enabled
if *captureXML && loggingTransport != nil {
fmt.Println()
logStepf("Creating XML capture archive...")
logStepf("Creating V2 XML capture archive...")
// V2: Save metadata.json before creating archive
if err := loggingTransport.SaveMetadata(report); err != nil {
logErrorf("Failed to save metadata: %v", err)
} else {
logSuccessf("V2 metadata.json generated")
}
// Generate archive name based on device info
var archiveName string
@@ -344,10 +367,10 @@ func main() {
archivePath := filepath.Join(*outputDir, archiveName)
if err := createTarGz(xmlCaptureDir, archivePath); err != nil {
if err := createTarGzV2(xmlCaptureDir, archivePath); err != nil {
logErrorf("Failed to create XML archive: %v", err)
} else {
logSuccessf("XML archive created: %s", archiveName)
logSuccessf("V2 XML archive created: %s", archiveName)
logSuccessf("Total SOAP calls captured: %d", loggingTransport.Counter)
// Remove temporary directory
@@ -912,18 +935,452 @@ func logInfof(format string, args ...interface{}) {
fmt.Printf(" %s\n", fmt.Sprintf(format, args...))
}
// =============================================================================
// Comprehensive Capture Mode
// =============================================================================
// runComprehensiveCapture captures all READ operations from the camera.
// This function exercises the full API to create a comprehensive test fixture.
//
//nolint:funlen,gocognit,gocyclo // Comprehensive capture requires many operations
func runComprehensiveCapture(ctx context.Context, client *onvif.Client, report *CameraReport) {
successCount := 0
failCount := 0
totalOps := 0
// Phase 1: Get device information first (needed for report)
logStepf("Phase 1: Core device information...")
report.DeviceInfo = testGetDeviceInformation(ctx, client, report)
if report.DeviceInfo != nil && report.DeviceInfo.Success {
successCount++
} else {
failCount++
}
totalOps++
report.SystemDateTime = testGetSystemDateTime(ctx, client, report)
if report.SystemDateTime != nil && report.SystemDateTime.Success {
successCount++
} else {
failCount++
}
totalOps++
report.Capabilities = testGetCapabilities(ctx, client, report)
if report.Capabilities != nil && report.Capabilities.Success {
successCount++
} else {
failCount++
}
totalOps++
// Phase 2: Initialize to discover service endpoints
logStepf("Phase 2: Service discovery...")
if err := client.Initialize(ctx); err != nil {
logErrorf("Service discovery failed: %v", err)
report.Errors = append(report.Errors, ErrorLog{
Operation: "Initialize",
Error: err.Error(),
Timestamp: time.Now().Format(time.RFC3339),
})
failCount++
} else {
logSuccessf("Service endpoints discovered")
successCount++
}
totalOps++
// Phase 3: Device service operations (no dependencies)
logStepf("Phase 3: Device service operations...")
deviceOps := []struct {
name string
fn func() error
}{
{"GetHostname", func() error { _, err := client.GetHostname(ctx); return err }},
{"GetDNS", func() error { _, err := client.GetDNS(ctx); return err }},
{"GetNTP", func() error { _, err := client.GetNTP(ctx); return err }},
{"GetNetworkInterfaces", func() error { _, err := client.GetNetworkInterfaces(ctx); return err }},
{"GetNetworkProtocols", func() error { _, err := client.GetNetworkProtocols(ctx); return err }},
{"GetNetworkDefaultGateway", func() error { _, err := client.GetNetworkDefaultGateway(ctx); return err }},
{"GetScopes", func() error { _, err := client.GetScopes(ctx); return err }},
{"GetUsers", func() error { _, err := client.GetUsers(ctx); return err }},
{"GetDiscoveryMode", func() error { _, err := client.GetDiscoveryMode(ctx); return err }},
{"GetRemoteDiscoveryMode", func() error { _, err := client.GetRemoteDiscoveryMode(ctx); return err }},
{"GetEndpointReference", func() error { _, err := client.GetEndpointReference(ctx); return err }},
{"GetRelayOutputs", func() error { _, err := client.GetRelayOutputs(ctx); return err }},
{"GetRemoteUser", func() error { _, err := client.GetRemoteUser(ctx); return err }},
{"GetIPAddressFilter", func() error { _, err := client.GetIPAddressFilter(ctx); return err }},
{"GetZeroConfiguration", func() error { _, err := client.GetZeroConfiguration(ctx); return err }},
{"GetServices", func() error { _, err := client.GetServices(ctx, true); return err }},
{"GetServiceCapabilities", func() error { _, err := client.GetServiceCapabilities(ctx); return err }},
{"GetStorageConfigurations", func() error { _, err := client.GetStorageConfigurations(ctx); return err }},
{"GetGeoLocation", func() error { _, err := client.GetGeoLocation(ctx); return err }},
{"GetDPAddresses", func() error { _, err := client.GetDPAddresses(ctx); return err }},
{"GetAccessPolicy", func() error { _, err := client.GetAccessPolicy(ctx); return err }},
{"GetWsdlURL", func() error { _, err := client.GetWsdlURL(ctx); return err }},
{"GetPasswordComplexityConfiguration", func() error { _, err := client.GetPasswordComplexityConfiguration(ctx); return err }},
{"GetPasswordHistoryConfiguration", func() error { _, err := client.GetPasswordHistoryConfiguration(ctx); return err }},
{"GetAuthFailureWarningConfiguration", func() error { _, err := client.GetAuthFailureWarningConfiguration(ctx); return err }},
}
for _, op := range deviceOps {
if err := op.fn(); err != nil {
if *verbose {
logErrorf("%s: %v", op.name, err)
}
failCount++
} else {
if *verbose {
logSuccessf("%s", op.name)
}
successCount++
}
totalOps++
}
logSuccessf("Device operations: %d captured", len(deviceOps))
// Phase 4: Media service - Get profiles and sources
logStepf("Phase 4: Media profiles and sources...")
report.Profiles = testGetProfiles(ctx, client, report)
totalOps++
if report.Profiles != nil && report.Profiles.Success {
successCount++
} else {
failCount++
}
// Get video sources
videoSources, err := client.GetVideoSources(ctx)
totalOps++
if err != nil {
if *verbose {
logErrorf("GetVideoSources: %v", err)
}
failCount++
} else {
if *verbose {
logSuccessf("GetVideoSources: %d sources", len(videoSources))
}
successCount++
}
// Get audio sources
audioSources, err := client.GetAudioSources(ctx)
totalOps++
if err != nil {
if *verbose {
logErrorf("GetAudioSources: %v", err)
}
failCount++
} else {
if *verbose {
logSuccessf("GetAudioSources: %d sources", len(audioSources))
}
successCount++
}
// Get audio outputs
_, err = client.GetAudioOutputs(ctx)
totalOps++
if err != nil {
if *verbose {
logErrorf("GetAudioOutputs: %v", err)
}
failCount++
} else {
if *verbose {
logSuccessf("GetAudioOutputs")
}
successCount++
}
// Phase 5: Profile-dependent operations
if report.Profiles != nil && report.Profiles.Success && len(report.Profiles.Data) > 0 {
logStepf("Phase 5: Profile-dependent operations...")
for _, profile := range report.Profiles.Data {
// GetProfile
_, err := client.GetProfile(ctx, profile.Token)
totalOps++
if err != nil {
failCount++
} else {
successCount++
}
// GetStreamURI
_, err = client.GetStreamURI(ctx, profile.Token)
totalOps++
if err != nil {
failCount++
} else {
successCount++
}
// GetSnapshotURI
_, err = client.GetSnapshotURI(ctx, profile.Token)
totalOps++
if err != nil {
failCount++
} else {
successCount++
}
// PTZ operations (if PTZ configuration exists)
if profile.PTZConfiguration != nil {
_, err = client.GetStatus(ctx, profile.Token)
totalOps++
if err != nil {
failCount++
} else {
successCount++
}
_, err = client.GetPresets(ctx, profile.Token)
totalOps++
if err != nil {
failCount++
} else {
successCount++
}
}
// Video encoder configuration
if profile.VideoEncoderConfiguration != nil {
_, err = client.GetVideoEncoderConfiguration(ctx, profile.VideoEncoderConfiguration.Token)
totalOps++
if err != nil {
failCount++
} else {
successCount++
}
_, err = client.GetVideoEncoderConfigurationOptions(ctx, profile.VideoEncoderConfiguration.Token)
totalOps++
if err != nil {
failCount++
} else {
successCount++
}
}
// Audio encoder configuration
if profile.AudioEncoderConfiguration != nil {
_, err = client.GetAudioEncoderConfiguration(ctx, profile.AudioEncoderConfiguration.Token)
totalOps++
if err != nil {
failCount++
} else {
successCount++
}
}
}
logSuccessf("Profile operations completed for %d profiles", len(report.Profiles.Data))
}
// Phase 6: Video source dependent operations
if len(videoSources) > 0 {
logStepf("Phase 6: Video source operations...")
for _, source := range videoSources {
// Imaging settings
_, err := client.GetImagingSettings(ctx, source.Token)
totalOps++
if err != nil {
failCount++
} else {
successCount++
}
// Imaging options
_, err = client.GetOptions(ctx, source.Token)
totalOps++
if err != nil {
failCount++
} else {
successCount++
}
// Imaging move options
_, err = client.GetMoveOptions(ctx, source.Token)
totalOps++
if err != nil {
failCount++
} else {
successCount++
}
}
logSuccessf("Video source operations completed for %d sources", len(videoSources))
}
// Phase 7: Configuration listings
logStepf("Phase 7: Configuration listings...")
configOps := []struct {
name string
fn func() error
}{
{"GetVideoSourceConfigurations", func() error { _, err := client.GetVideoSourceConfigurations(ctx); return err }},
{"GetVideoEncoderConfigurations", func() error { _, err := client.GetVideoEncoderConfigurations(ctx); return err }},
{"GetAudioSourceConfigurations", func() error { _, err := client.GetAudioSourceConfigurations(ctx); return err }},
{"GetAudioEncoderConfigurations", func() error { _, err := client.GetAudioEncoderConfigurations(ctx); return err }},
{"GetAudioOutputConfigurations", func() error { _, err := client.GetAudioOutputConfigurations(ctx); return err }},
{"GetMetadataConfigurations", func() error { _, err := client.GetMetadataConfigurations(ctx); return err }},
{"GetMediaServiceCapabilities", func() error { _, err := client.GetMediaServiceCapabilities(ctx); return err }},
}
for _, op := range configOps {
if err := op.fn(); err != nil {
if *verbose {
logErrorf("%s: %v", op.name, err)
}
failCount++
} else {
if *verbose {
logSuccessf("%s", op.name)
}
successCount++
}
totalOps++
}
logSuccessf("Configuration listings: %d captured", len(configOps))
// Phase 8: Event service
logStepf("Phase 8: Event service...")
eventOps := []struct {
name string
fn func() error
}{
{"GetEventServiceCapabilities", func() error { _, err := client.GetEventServiceCapabilities(ctx); return err }},
{"GetEventProperties", func() error { _, err := client.GetEventProperties(ctx); return err }},
}
for _, op := range eventOps {
if err := op.fn(); err != nil {
if *verbose {
logErrorf("%s: %v", op.name, err)
}
failCount++
} else {
if *verbose {
logSuccessf("%s", op.name)
}
successCount++
}
totalOps++
}
logSuccessf("Event operations: %d captured", len(eventOps))
// Phase 9: Certificate operations
logStepf("Phase 9: Certificate and security operations...")
certOps := []struct {
name string
fn func() error
}{
{"GetCertificates", func() error { _, err := client.GetCertificates(ctx); return err }},
{"GetCACertificates", func() error { _, err := client.GetCACertificates(ctx); return err }},
{"GetCertificatesStatus", func() error { _, err := client.GetCertificatesStatus(ctx); return err }},
{"GetClientCertificateMode", func() error { _, err := client.GetClientCertificateMode(ctx); return err }},
}
for _, op := range certOps {
if err := op.fn(); err != nil {
if *verbose {
logErrorf("%s: %v", op.name, err)
}
failCount++
} else {
if *verbose {
logSuccessf("%s", op.name)
}
successCount++
}
totalOps++
}
logSuccessf("Certificate operations: %d captured", len(certOps))
// Phase 10: WiFi operations (may not be supported by all cameras)
logStepf("Phase 10: WiFi operations...")
wifiOps := []struct {
name string
fn func() error
}{
{"GetDot11Capabilities", func() error { _, err := client.GetDot11Capabilities(ctx); return err }},
{"GetDot1XConfigurations", func() error { _, err := client.GetDot1XConfigurations(ctx); return err }},
}
for _, op := range wifiOps {
if err := op.fn(); err != nil {
if *verbose {
logErrorf("%s: %v", op.name, err)
}
failCount++
} else {
if *verbose {
logSuccessf("%s", op.name)
}
successCount++
}
totalOps++
}
logSuccessf("WiFi operations: %d captured", len(wifiOps))
// Summary
fmt.Println()
fmt.Println("========================================")
fmt.Printf("Comprehensive capture complete!\n")
fmt.Printf(" Total operations: %d\n", totalOps)
fmt.Printf(" Successful: %d\n", successCount)
fmt.Printf(" Failed: %d\n", failCount)
fmt.Printf(" Success rate: %.1f%%\n", float64(successCount)/float64(totalOps)*100)
fmt.Println("========================================")
}
// XML Capture functionality
// XMLCapture stores a request/response pair.
// XMLCapture stores a request/response pair (V2 format with parameter awareness).
type XMLCapture struct {
Timestamp string `json:"timestamp"`
Operation int `json:"operation"`
// Version indicates the capture format version ("2.0" for V2)
Version string `json:"version"`
// Timestamp is when the exchange was captured (RFC3339 format)
Timestamp string `json:"timestamp"`
// Sequence is the capture order (1-indexed for V2)
Sequence int `json:"sequence"`
// Operation is deprecated in V2, kept for backward compatibility
Operation int `json:"operation,omitempty"`
// OperationName is the SOAP operation name (e.g., "GetDeviceInformation")
OperationName string `json:"operation_name"`
Endpoint string `json:"endpoint"`
RequestBody string `json:"request_body"`
ResponseBody string `json:"response_body"`
StatusCode int `json:"status_code"`
Error string `json:"error,omitempty"`
// ServiceType categorizes which ONVIF service handles this operation
ServiceType string `json:"service_type,omitempty"`
// Parameters contains extracted key parameters from the request
Parameters map[string]interface{} `json:"parameters,omitempty"`
// Endpoint is the URL the request was sent to
Endpoint string `json:"endpoint"`
// RequestBody is the full SOAP request XML
RequestBody string `json:"request_body"`
// ResponseBody is the full SOAP response XML
ResponseBody string `json:"response_body"`
// StatusCode is the HTTP response status code
StatusCode int `json:"status_code"`
// DurationNs is the request duration in nanoseconds
DurationNs int64 `json:"duration_ns,omitempty"`
// Success indicates if the operation succeeded (no SOAP fault)
Success bool `json:"success"`
// Error contains error message if the operation failed
Error string `json:"error,omitempty"`
}
// LoggingTransport wraps http.RoundTripper to log requests and responses.
@@ -931,13 +1388,24 @@ type LoggingTransport struct {
Transport http.RoundTripper
LogDir string
Counter int
// V2 additions for metadata generation
captures []*XMLCapture
serviceMap map[string]string // operation -> service type
mu sync.Mutex
}
func (t *LoggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
t.mu.Lock()
t.Counter++
sequence := t.Counter
t.mu.Unlock()
startTime := time.Now()
capture := XMLCapture{
Timestamp: time.Now().Format(time.RFC3339),
Operation: t.Counter,
Version: onviftesting.CaptureVersion,
Timestamp: startTime.Format(time.RFC3339),
Sequence: sequence,
Operation: sequence, // Keep for backward compatibility
Endpoint: req.URL.String(),
}
@@ -948,6 +1416,11 @@ func (t *LoggingTransport) RoundTrip(req *http.Request) (*http.Response, error)
capture.RequestBody = string(bodyBytes)
// Extract operation name from SOAP body
capture.OperationName = extractSOAPOperation(capture.RequestBody)
// V2: Extract service type
serviceType := onviftesting.DetermineServiceType(capture.RequestBody)
capture.ServiceType = string(serviceType)
// V2: Extract parameters
capture.Parameters = onviftesting.ExtractParameters(capture.OperationName, capture.RequestBody)
// Restore the body for the actual request
req.Body = io.NopCloser(strings.NewReader(string(bodyBytes)))
}
@@ -955,8 +1428,13 @@ func (t *LoggingTransport) RoundTrip(req *http.Request) (*http.Response, error)
// Make the actual request
resp, err := t.Transport.RoundTrip(req)
// V2: Track request duration
capture.DurationNs = time.Since(startTime).Nanoseconds()
if err != nil {
capture.Error = err.Error()
capture.Success = false
t.saveCapture(&capture)
return nil, fmt.Errorf("round trip failed: %w", err)
@@ -973,6 +1451,12 @@ func (t *LoggingTransport) RoundTrip(req *http.Request) (*http.Response, error)
}
}
// V2: Determine success (no SOAP fault and 2xx status)
capture.Success = resp.StatusCode >= 200 && resp.StatusCode < 300 &&
!strings.Contains(capture.ResponseBody, "<soap:Fault>") &&
!strings.Contains(capture.ResponseBody, "<Fault>") &&
!strings.Contains(capture.ResponseBody, ":Fault>")
t.saveCapture(&capture)
return resp, nil
@@ -1012,8 +1496,19 @@ func prettyPrintXML(xmlStr string) string {
}
func (t *LoggingTransport) saveCapture(capture *XMLCapture) {
// Create filename base using operation name
baseFilename := fmt.Sprintf("capture_%03d_%s", capture.Operation, capture.OperationName)
// V2: Track capture for metadata generation
t.mu.Lock()
t.captures = append(t.captures, capture)
if t.serviceMap == nil {
t.serviceMap = make(map[string]string)
}
if capture.ServiceType != "" && capture.ServiceType != "Unknown" {
t.serviceMap[capture.OperationName] = capture.ServiceType
}
t.mu.Unlock()
// Create filename base using sequence and operation name
baseFilename := fmt.Sprintf("capture_%03d_%s", capture.Sequence, capture.OperationName)
// Save as individual JSON file
filename := filepath.Join(t.LogDir, baseFilename+".json")
@@ -1046,6 +1541,50 @@ func (t *LoggingTransport) saveCapture(capture *XMLCapture) {
}
}
// GenerateMetadata creates the V2 metadata.json file from captured exchanges.
func (t *LoggingTransport) GenerateMetadata(report *CameraReport) *onviftesting.CaptureMetadata {
t.mu.Lock()
defer t.mu.Unlock()
metadata := &onviftesting.CaptureMetadata{
Version: onviftesting.CaptureVersion,
CreatedAt: time.Now(),
ToolVersion: version,
TotalExchanges: len(t.captures),
ServiceMap: t.serviceMap,
}
// Extract camera info from report
if report.DeviceInfo != nil && report.DeviceInfo.Success && report.DeviceInfo.Data != nil {
metadata.CameraInfo = onviftesting.CameraInfo{
Manufacturer: report.DeviceInfo.Data.Manufacturer,
Model: report.DeviceInfo.Data.Model,
FirmwareVersion: report.DeviceInfo.Data.FirmwareVersion,
SerialNumber: report.DeviceInfo.Data.SerialNumber,
HardwareID: report.DeviceInfo.Data.HardwareID,
}
}
return metadata
}
// SaveMetadata writes the metadata.json file to the log directory.
func (t *LoggingTransport) SaveMetadata(report *CameraReport) error {
metadata := t.GenerateMetadata(report)
data, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal metadata: %w", err)
}
filename := filepath.Join(t.LogDir, "metadata.json")
if err := os.WriteFile(filename, data, 0600); err != nil { //nolint:mnd // 0600 appropriate for diagnostic files
return fmt.Errorf("failed to write metadata: %w", err)
}
return nil
}
// extractSOAPOperation extracts the operation name from a SOAP request body.
func extractSOAPOperation(soapBody string) string {
// Look for the operation element in the SOAP Body
@@ -1094,8 +1633,8 @@ func extractSOAPOperation(soapBody string) string {
return "Unknown"
}
// createTarGz creates a tar.gz archive from a directory.
func createTarGz(sourceDir, archivePath string) error {
// createTarGzV2 creates a V2 tar.gz archive with metadata.json first.
func createTarGzV2(sourceDir, archivePath string) error {
// Create archive file
archiveFile, err := os.Create(archivePath) //nolint:gosec // Archive path is validated before use
if err != nil {
@@ -1117,16 +1656,54 @@ func createTarGz(sourceDir, archivePath string) error {
_ = tarWriter.Close()
}()
// Walk through source directory
// V2: Collect all files and sort them with metadata.json first
var files []string
if err := filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Skip the root directory itself
if path == sourceDir {
if path == sourceDir || info.IsDir() {
return nil
}
files = append(files, path)
return nil
}); err != nil {
return fmt.Errorf("failed to walk source directory: %w", err)
}
// Sort files: metadata.json first, then capture JSON files in order, then XML files
sort.Slice(files, func(i, j int) bool {
nameI := filepath.Base(files[i])
nameJ := filepath.Base(files[j])
// metadata.json always first
if nameI == "metadata.json" {
return true
}
if nameJ == "metadata.json" {
return false
}
// JSON files before XML files
isJSONi := strings.HasSuffix(nameI, ".json")
isJSONj := strings.HasSuffix(nameJ, ".json")
if isJSONi && !isJSONj {
return true
}
if !isJSONi && isJSONj {
return false
}
// Sort by name
return nameI < nameJ
})
// Write files in sorted order
for _, path := range files {
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("failed to stat file: %w", err)
}
// Create tar header
header, err := tar.FileInfoHeader(info, "")
@@ -1146,24 +1723,17 @@ func createTarGz(sourceDir, archivePath string) error {
return fmt.Errorf("failed to write tar header: %w", err)
}
// If it's a file, write its content
if !info.IsDir() {
file, err := os.Open(path) //nolint:gosec // File path is from filepath.Walk, safe
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer func() {
_ = file.Close()
}()
if _, err := io.Copy(tarWriter, file); err != nil {
return fmt.Errorf("failed to write file to tar: %w", err)
}
// Write file content
file, err := os.Open(path) //nolint:gosec // File path is from filepath.Walk, safe
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
return nil
}); err != nil {
return fmt.Errorf("failed to walk source directory: %w", err)
if _, err := io.Copy(tarWriter, file); err != nil {
_ = file.Close()
return fmt.Errorf("failed to write file to tar: %w", err)
}
_ = file.Close()
}
return nil