From 24b17e3e0bea464df69f3ba05c7d8e6fb3f94a43 Mon Sep 17 00:00:00 2001 From: ProtoTess <32490978+0x524A@users.noreply.github.com> Date: Mon, 17 Nov 2025 03:32:32 +0000 Subject: [PATCH] fix: update .gitignore to preserve cmd/ source directories and add missing CLI tools --- .gitignore | 12 +- cmd/generate-tests/README.md | 236 +++++++ cmd/generate-tests/main.go | 263 ++++++++ cmd/onvif-diagnostics/README.md | 365 ++++++++++ cmd/onvif-diagnostics/main.go | 1115 +++++++++++++++++++++++++++++++ 5 files changed, 1986 insertions(+), 5 deletions(-) create mode 100644 cmd/generate-tests/README.md create mode 100644 cmd/generate-tests/main.go create mode 100644 cmd/onvif-diagnostics/README.md create mode 100644 cmd/onvif-diagnostics/main.go diff --git a/.gitignore b/.gitignore index 3493109..98e41f8 100644 --- a/.gitignore +++ b/.gitignore @@ -26,14 +26,16 @@ go.work *~ .DS_Store -# Binaries +# Binaries (in root, bin, or dist directories) bin/ dist/ releases/ -onvif-diagnostics -onvif-server -onvif-server-example -generate-tests +/onvif-diagnostics +/onvif-server +/onvif-server-example +/generate-tests +/onvif-cli +/onvif-quick # Temporary files tmp/ diff --git a/cmd/generate-tests/README.md b/cmd/generate-tests/README.md new file mode 100644 index 0000000..5032bce --- /dev/null +++ b/cmd/generate-tests/README.md @@ -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(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(t *testing.T) { + captureArchive := ".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: + +``` +___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 diff --git a/cmd/generate-tests/main.go b/cmd/generate-tests/main.go new file mode 100644 index 0000000..4dcaec6 --- /dev/null +++ b/cmd/generate-tests/main.go @@ -0,0 +1,263 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "text/template" + + 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") +) + +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 +func Test{{.CameraName}}(t *testing.T) { + // Load capture archive (relative to project root) + captureArchive := "{{.CaptureArchiveRelPath}}" + + mockServer, err := onviftesting.NewMockSOAPServer(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() + + 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) + }) + + t.Run("GetSystemDateAndTime", func(t *testing.T) { + _, err := client.GetSystemDateAndTime(ctx) + if err != nil { + t.Errorf("GetSystemDateAndTime failed: %v", err) + } + }) + + 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) + }) + + 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) + } + }) +{{range .AdditionalTests}} + t.Run("{{.Name}}", func(t *testing.T) { + {{.Code}} + }) +{{end}} +} +` + +type TestData struct { + PackageName string + CameraName string + CameraDescription string + CaptureArchiveRelPath string + AdditionalTests []AdditionalTest +} + +type AdditionalTest struct { + Name string + Code string +} + +func main() { + flag.Parse() + + 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") + os.Exit(1) + } + + // Load capture to get camera info + capture, err := onviftesting.LoadCaptureFromArchive(*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] + + // Convert to valid Go identifier + cameraName := strings.ReplaceAll(cameraID, "-", "") + cameraName = strings.ReplaceAll(cameraName, ".", "") + cameraName = strings.ReplaceAll(cameraName, " ", "") + + // Get device info from first exchange (GetDeviceInformation) + cameraDesc := cameraID + if len(capture.Exchanges) > 0 { + // Try to parse device info from response + for _, ex := range capture.Exchanges { + if strings.Contains(ex.RequestBody, "GetDeviceInformation") { + // 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 + } + } + } + + testData := TestData{ + PackageName: *packageName, + CameraName: cameraName, + CameraDescription: cameraDesc, + CaptureArchiveRelPath: relArchivePath, + AdditionalTests: []AdditionalTest{}, + } + + // Generate test file + tmpl, err := template.New("test").Parse(testTemplate) + if err != nil { + 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) + if err != nil { + log.Fatalf("Failed to create output file: %v", err) + } + defer f.Close() + + if err := tmpl.Execute(f, testData); err != nil { + 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.Println() + fmt.Println("Run tests with:") + fmt.Printf(" go test -v %s\n", outputFile) +} + +func extractXMLValue(xmlStr, tagName string) string { + // Simple extraction for basic tags + start := fmt.Sprintf("<%s>", tagName) + end := fmt.Sprintf("", 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 { + return "" + } + startIdx += len(start) + } else { + startIdx += len(start) + } + + 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 { + return "" + } + } + + return strings.TrimSpace(xmlStr[startIdx : startIdx+endIdx]) +} diff --git a/cmd/onvif-diagnostics/README.md b/cmd/onvif-diagnostics/README.md new file mode 100644 index 0000000..98d7dc3 --- /dev/null +++ b/cmd/onvif-diagnostics/README.md @@ -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/go-onvif +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 + diff --git a/cmd/onvif-diagnostics/main.go b/cmd/onvif-diagnostics/main.go new file mode 100644 index 0000000..964fc7d --- /dev/null +++ b/cmd/onvif-diagnostics/main.go @@ -0,0 +1,1115 @@ +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "encoding/xml" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/0x524a/onvif-go" +) + +const version = "1.0.0" + +type CameraReport struct { + Timestamp string `json:"timestamp"` + UtilityVersion string `json:"utility_version"` + ConnectionInfo ConnectionInfo `json:"connection_info"` + DeviceInfo *DeviceInfoResult `json:"device_info"` + Capabilities *CapabilitiesResult `json:"capabilities"` + Profiles *ProfilesResult `json:"profiles"` + StreamURIs []StreamURIResult `json:"stream_uris"` + SnapshotURIs []SnapshotURIResult `json:"snapshot_uris"` + VideoEncoders []VideoEncoderResult `json:"video_encoders"` + ImagingSettings []ImagingSettingsResult `json:"imaging_settings"` + PTZStatus []PTZStatusResult `json:"ptz_status"` + PTZPresets []PTZPresetsResult `json:"ptz_presets"` + SystemDateTime *SystemDateTimeResult `json:"system_datetime"` + RawResponses map[string]interface{} `json:"raw_responses,omitempty"` + Errors []ErrorLog `json:"errors"` +} + +type ConnectionInfo struct { + Endpoint string `json:"endpoint"` + Username string `json:"username"` + TestDate string `json:"test_date"` +} + +type DeviceInfoResult struct { + Success bool `json:"success"` + Data *onvif.DeviceInformation `json:"data,omitempty"` + Error string `json:"error,omitempty"` + ResponseTime string `json:"response_time"` +} + +type CapabilitiesResult struct { + Success bool `json:"success"` + Data *onvif.Capabilities `json:"data,omitempty"` + Error string `json:"error,omitempty"` + ResponseTime string `json:"response_time"` +} + +type ProfilesResult struct { + Success bool `json:"success"` + Data []*onvif.Profile `json:"data,omitempty"` + Count int `json:"count"` + Error string `json:"error,omitempty"` + ResponseTime string `json:"response_time"` +} + +type StreamURIResult struct { + ProfileToken string `json:"profile_token"` + ProfileName string `json:"profile_name"` + Success bool `json:"success"` + Data *onvif.MediaURI `json:"data,omitempty"` + Error string `json:"error,omitempty"` + ResponseTime string `json:"response_time"` +} + +type SnapshotURIResult struct { + ProfileToken string `json:"profile_token"` + ProfileName string `json:"profile_name"` + Success bool `json:"success"` + Data *onvif.MediaURI `json:"data,omitempty"` + Error string `json:"error,omitempty"` + ResponseTime string `json:"response_time"` +} + +type VideoEncoderResult struct { + ProfileToken string `json:"profile_token"` + ProfileName string `json:"profile_name"` + Success bool `json:"success"` + Data *onvif.VideoEncoderConfiguration `json:"data,omitempty"` + Error string `json:"error,omitempty"` + ResponseTime string `json:"response_time"` +} + +type ImagingSettingsResult struct { + VideoSourceToken string `json:"video_source_token"` + Success bool `json:"success"` + Data *onvif.ImagingSettings `json:"data,omitempty"` + Error string `json:"error,omitempty"` + ResponseTime string `json:"response_time"` +} + +type PTZStatusResult struct { + ProfileToken string `json:"profile_token"` + ProfileName string `json:"profile_name"` + Success bool `json:"success"` + Data *onvif.PTZStatus `json:"data,omitempty"` + Error string `json:"error,omitempty"` + ResponseTime string `json:"response_time"` +} + +type PTZPresetsResult struct { + ProfileToken string `json:"profile_token"` + ProfileName string `json:"profile_name"` + Success bool `json:"success"` + Data []*onvif.PTZPreset `json:"data,omitempty"` + Count int `json:"count"` + Error string `json:"error,omitempty"` + ResponseTime string `json:"response_time"` +} + +type SystemDateTimeResult struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` + ResponseTime string `json:"response_time"` +} + +type ErrorLog struct { + Operation string `json:"operation"` + Error string `json:"error"` + Timestamp string `json:"timestamp"` +} + +var ( + endpoint = flag.String("endpoint", "", "ONVIF device endpoint (e.g., http://192.168.1.201/onvif/device_service)") + username = flag.String("username", "", "ONVIF username") + password = flag.String("password", "", "ONVIF password") + outputDir = flag.String("output", "./camera-logs", "Output directory for logs") + timeout = flag.Int("timeout", 30, "Request timeout in seconds") + verbose = flag.Bool("verbose", false, "Verbose output") + captureXML = flag.Bool("capture-xml", false, "Capture raw SOAP XML traffic and create tar.gz archive") + includeRaw = flag.Bool("include-raw", false, "Include raw SOAP responses (increases file size)") +) + +func main() { + flag.Parse() + + fmt.Printf("ONVIF Camera Diagnostic Utility v%s\n", version) + fmt.Println("========================================") + fmt.Println() + + // Validate inputs + if *endpoint == "" || *username == "" || *password == "" { + fmt.Println("Error: Missing required parameters") + fmt.Println() + fmt.Println("Usage:") + flag.PrintDefaults() + fmt.Println() + fmt.Println("Example:") + fmt.Println(" ./onvif-diagnostics -endpoint http://192.168.1.201/onvif/device_service -username service -password Service.1234") + os.Exit(1) + } + + // Create output directory + if err := os.MkdirAll(*outputDir, 0755); err != nil { + log.Fatalf("Failed to create output directory: %v", err) + } + + // Initialize report + report := &CameraReport{ + Timestamp: time.Now().Format(time.RFC3339), + UtilityVersion: version, + ConnectionInfo: ConnectionInfo{ + Endpoint: *endpoint, + Username: *username, + TestDate: time.Now().Format("2006-01-02"), + }, + Errors: make([]ErrorLog, 0), + RawResponses: make(map[string]interface{}), + } + + // Setup XML capture if requested + var loggingTransport *LoggingTransport + var xmlCaptureDir string + + if *captureXML { + timestamp := time.Now().Format("20060102-150405") + xmlCaptureDir = filepath.Join(*outputDir, "temp_"+timestamp) + if err := os.MkdirAll(xmlCaptureDir, 0755); err != nil { + log.Fatalf("Failed to create XML capture directory: %v", err) + } + + loggingTransport = &LoggingTransport{ + Transport: &http.Transport{ + MaxIdleConns: 10, + MaxIdleConnsPerHost: 5, + IdleConnTimeout: 90 * time.Second, + }, + LogDir: xmlCaptureDir, + Counter: 0, + } + + if *verbose { + fmt.Printf("📦 XML capture enabled, saving to: %s\n", xmlCaptureDir) + } + } + + // Create ONVIF client + var client *onvif.Client + var err error + + if loggingTransport != nil { + httpClient := &http.Client{ + Timeout: time.Duration(*timeout) * time.Second, + Transport: loggingTransport, + } + client, err = onvif.NewClient( + *endpoint, + onvif.WithCredentials(*username, *password), + onvif.WithHTTPClient(httpClient), + ) + } else { + client, err = onvif.NewClient( + *endpoint, + onvif.WithCredentials(*username, *password), + onvif.WithTimeout(time.Duration(*timeout)*time.Second), + ) + } + + if err != nil { + log.Fatalf("Failed to create ONVIF client: %v", err) + } + + ctx := context.Background() + + fmt.Println("Starting diagnostic collection...") + fmt.Println() + + // Test 1: Get Device Information + logStep("1. Getting device information...") + report.DeviceInfo = testGetDeviceInformation(ctx, client, report) + + // Test 2: Get System Date and Time + logStep("2. Getting system date and time...") + report.SystemDateTime = testGetSystemDateTime(ctx, client, report) + + // Test 3: Get Capabilities + logStep("3. Getting capabilities...") + report.Capabilities = testGetCapabilities(ctx, client, report) + + // Test 4: Initialize (discover services) + logStep("4. Discovering service endpoints...") + if err := client.Initialize(ctx); err != nil { + logError("Service discovery failed: %v", err) + report.Errors = append(report.Errors, ErrorLog{ + Operation: "Initialize", + Error: err.Error(), + Timestamp: time.Now().Format(time.RFC3339), + }) + } else { + logSuccess("Service endpoints discovered") + } + + // Test 5: Get Profiles + logStep("5. Getting media profiles...") + report.Profiles = testGetProfiles(ctx, client, report) + + // Test 6: Get Stream URIs (for each profile) + if report.Profiles != nil && report.Profiles.Success { + logStep("6. Getting stream URIs for all profiles...") + report.StreamURIs = testGetStreamURIs(ctx, client, report.Profiles.Data, report) + } + + // Test 7: Get Snapshot URIs (for each profile) + if report.Profiles != nil && report.Profiles.Success { + logStep("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 { + logStep("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 { + logStep("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 { + logStep("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 { + logStep("11. Getting PTZ presets...") + report.PTZPresets = testGetPTZPresets(ctx, client, report.Profiles.Data, report) + } + + // Generate output filename based on device info + filename := generateFilename(report) + outputPath := filepath.Join(*outputDir, filename) + + // Save report + logStep("Saving diagnostic report...") + if err := saveReport(report, outputPath); err != nil { + log.Fatalf("Failed to save report: %v", err) + } + + // Create XML archive if capture was enabled + if *captureXML && loggingTransport != nil { + fmt.Println() + logStep("Creating XML capture archive...") + + // Generate archive name based on device info + var archiveName string + if report.DeviceInfo != nil && report.DeviceInfo.Success { + manufacturer := sanitizeFilename(report.DeviceInfo.Data.Manufacturer) + model := sanitizeFilename(report.DeviceInfo.Data.Model) + firmware := sanitizeFilename(report.DeviceInfo.Data.FirmwareVersion) + timestamp := time.Now().Format("20060102-150405") + archiveName = fmt.Sprintf("%s_%s_%s_xmlcapture_%s.tar.gz", manufacturer, model, firmware, timestamp) + } else { + timestamp := time.Now().Format("20060102-150405") + archiveName = fmt.Sprintf("unknown_device_xmlcapture_%s.tar.gz", timestamp) + } + + archivePath := filepath.Join(*outputDir, archiveName) + + if err := createTarGz(xmlCaptureDir, archivePath); err != nil { + logError("Failed to create XML archive: %v", err) + } else { + logSuccess("XML archive created: %s", archiveName) + logSuccess("Total SOAP calls captured: %d", loggingTransport.Counter) + + // Remove temporary directory + if err := os.RemoveAll(xmlCaptureDir); err != nil { + logError("Warning: Failed to remove temp directory: %v", err) + } + } + } + + fmt.Println() + fmt.Println("========================================") + fmt.Printf("✓ Diagnostic collection complete!\n") + fmt.Printf(" Report saved to: %s\n", outputPath) + fmt.Printf(" Total errors: %d\n", len(report.Errors)) + + if report.DeviceInfo != nil && report.DeviceInfo.Success { + fmt.Printf("\n Device: %s %s\n", report.DeviceInfo.Data.Manufacturer, report.DeviceInfo.Data.Model) + fmt.Printf(" Firmware: %s\n", report.DeviceInfo.Data.FirmwareVersion) + } + + if report.Profiles != nil && report.Profiles.Success { + fmt.Printf(" Profiles: %d\n", report.Profiles.Count) + } + + fmt.Println() + if *captureXML { + fmt.Println("Both JSON report and XML capture archive saved to camera-logs/") + fmt.Println("Share both files for comprehensive analysis.") + } else { + fmt.Println("Use -capture-xml flag to also capture raw SOAP XML traffic.") + fmt.Println("Please share this file for analysis and test creation.") + } + fmt.Println("========================================") +} + +func testGetDeviceInformation(ctx context.Context, client *onvif.Client, report *CameraReport) *DeviceInfoResult { + start := time.Now() + result := &DeviceInfoResult{} + + info, err := client.GetDeviceInformation(ctx) + result.ResponseTime = time.Since(start).String() + + if err != nil { + result.Success = false + result.Error = err.Error() + logError("Failed: %v", err) + report.Errors = append(report.Errors, ErrorLog{ + Operation: "GetDeviceInformation", + Error: err.Error(), + Timestamp: time.Now().Format(time.RFC3339), + }) + } else { + result.Success = true + result.Data = info + logSuccess("Manufacturer: %s, Model: %s", info.Manufacturer, info.Model) + } + + return result +} + +func testGetSystemDateTime(ctx context.Context, client *onvif.Client, report *CameraReport) *SystemDateTimeResult { + start := time.Now() + result := &SystemDateTimeResult{} + + dateTime, err := client.GetSystemDateAndTime(ctx) + result.ResponseTime = time.Since(start).String() + + if err != nil { + result.Success = false + result.Error = err.Error() + logError("Failed: %v", err) + report.Errors = append(report.Errors, ErrorLog{ + Operation: "GetSystemDateAndTime", + Error: err.Error(), + Timestamp: time.Now().Format(time.RFC3339), + }) + } else { + result.Success = true + result.Data = dateTime + logSuccess("Retrieved") + } + + return result +} + +func testGetCapabilities(ctx context.Context, client *onvif.Client, report *CameraReport) *CapabilitiesResult { + start := time.Now() + result := &CapabilitiesResult{} + + capabilities, err := client.GetCapabilities(ctx) + result.ResponseTime = time.Since(start).String() + + if err != nil { + result.Success = false + result.Error = err.Error() + logError("Failed: %v", err) + report.Errors = append(report.Errors, ErrorLog{ + Operation: "GetCapabilities", + Error: err.Error(), + Timestamp: time.Now().Format(time.RFC3339), + }) + } else { + result.Success = true + result.Data = capabilities + + services := []string{} + if capabilities.Device != nil { + services = append(services, "Device") + } + if capabilities.Media != nil { + services = append(services, "Media") + } + if capabilities.PTZ != nil { + services = append(services, "PTZ") + } + if capabilities.Imaging != nil { + services = append(services, "Imaging") + } + if capabilities.Events != nil { + services = append(services, "Events") + } + if capabilities.Analytics != nil { + services = append(services, "Analytics") + } + + logSuccess("Services: %s", strings.Join(services, ", ")) + } + + return result +} + +func testGetProfiles(ctx context.Context, client *onvif.Client, report *CameraReport) *ProfilesResult { + start := time.Now() + result := &ProfilesResult{} + + profiles, err := client.GetProfiles(ctx) + result.ResponseTime = time.Since(start).String() + + if err != nil { + result.Success = false + result.Error = err.Error() + logError("Failed: %v", err) + report.Errors = append(report.Errors, ErrorLog{ + Operation: "GetProfiles", + Error: err.Error(), + Timestamp: time.Now().Format(time.RFC3339), + }) + } else { + result.Success = true + result.Data = profiles + result.Count = len(profiles) + logSuccess("Found %d profile(s)", len(profiles)) + + for i, profile := range profiles { + if *verbose { + fmt.Printf(" Profile %d: %s (Token: %s)\n", i+1, profile.Name, profile.Token) + if profile.VideoEncoderConfiguration != nil && profile.VideoEncoderConfiguration.Resolution != nil { + fmt.Printf(" Resolution: %dx%d, Encoding: %s\n", + profile.VideoEncoderConfiguration.Resolution.Width, + profile.VideoEncoderConfiguration.Resolution.Height, + profile.VideoEncoderConfiguration.Encoding) + } + } + } + } + + return result +} + +func testGetStreamURIs(ctx context.Context, client *onvif.Client, profiles []*onvif.Profile, report *CameraReport) []StreamURIResult { + results := make([]StreamURIResult, 0) + + for _, profile := range profiles { + start := time.Now() + result := StreamURIResult{ + ProfileToken: profile.Token, + ProfileName: profile.Name, + } + + streamURI, err := client.GetStreamURI(ctx, profile.Token) + result.ResponseTime = time.Since(start).String() + + if err != nil { + result.Success = false + result.Error = err.Error() + if *verbose { + logError(" Profile %s: %v", profile.Name, err) + } + report.Errors = append(report.Errors, ErrorLog{ + Operation: fmt.Sprintf("GetStreamURI[%s]", profile.Token), + Error: err.Error(), + Timestamp: time.Now().Format(time.RFC3339), + }) + } else { + result.Success = true + result.Data = streamURI + if *verbose { + logSuccess(" Profile %s: %s", profile.Name, streamURI.URI) + } + } + + results = append(results, result) + } + + successCount := 0 + for _, r := range results { + if r.Success { + successCount++ + } + } + logSuccess("Retrieved %d/%d stream URIs", successCount, len(results)) + + return results +} + +func testGetSnapshotURIs(ctx context.Context, client *onvif.Client, profiles []*onvif.Profile, report *CameraReport) []SnapshotURIResult { + results := make([]SnapshotURIResult, 0) + + for _, profile := range profiles { + start := time.Now() + result := SnapshotURIResult{ + ProfileToken: profile.Token, + ProfileName: profile.Name, + } + + snapshotURI, err := client.GetSnapshotURI(ctx, profile.Token) + result.ResponseTime = time.Since(start).String() + + if err != nil { + result.Success = false + result.Error = err.Error() + if *verbose { + logError(" Profile %s: %v", profile.Name, err) + } + report.Errors = append(report.Errors, ErrorLog{ + Operation: fmt.Sprintf("GetSnapshotURI[%s]", profile.Token), + Error: err.Error(), + Timestamp: time.Now().Format(time.RFC3339), + }) + } else { + result.Success = true + result.Data = snapshotURI + if *verbose { + logSuccess(" Profile %s: %s", profile.Name, snapshotURI.URI) + } + } + + results = append(results, result) + } + + successCount := 0 + for _, r := range results { + if r.Success { + successCount++ + } + } + logSuccess("Retrieved %d/%d snapshot URIs", successCount, len(results)) + + return results +} + +func testGetVideoEncoders(ctx context.Context, client *onvif.Client, profiles []*onvif.Profile, report *CameraReport) []VideoEncoderResult { + results := make([]VideoEncoderResult, 0) + + for _, profile := range profiles { + if profile.VideoEncoderConfiguration == nil { + continue + } + + start := time.Now() + result := VideoEncoderResult{ + ProfileToken: profile.Token, + ProfileName: profile.Name, + } + + config, err := client.GetVideoEncoderConfiguration(ctx, profile.VideoEncoderConfiguration.Token) + result.ResponseTime = time.Since(start).String() + + if err != nil { + result.Success = false + result.Error = err.Error() + if *verbose { + logError(" Profile %s: %v", profile.Name, err) + } + report.Errors = append(report.Errors, ErrorLog{ + Operation: fmt.Sprintf("GetVideoEncoderConfiguration[%s]", profile.Token), + Error: err.Error(), + Timestamp: time.Now().Format(time.RFC3339), + }) + } else { + result.Success = true + result.Data = config + if *verbose && config.Resolution != nil && config.RateControl != nil { + logSuccess(" Profile %s: %s %dx%d @ %dfps", + profile.Name, config.Encoding, + config.Resolution.Width, config.Resolution.Height, + config.RateControl.FrameRateLimit) + } + } + + results = append(results, result) + } + + successCount := 0 + for _, r := range results { + if r.Success { + successCount++ + } + } + logSuccess("Retrieved %d/%d video encoder configs", successCount, len(results)) + + return results +} + +func testGetImagingSettings(ctx context.Context, client *onvif.Client, profiles []*onvif.Profile, report *CameraReport) []ImagingSettingsResult { + results := make([]ImagingSettingsResult, 0) + processed := make(map[string]bool) + + for _, profile := range profiles { + if profile.VideoSourceConfiguration == nil { + continue + } + + token := profile.VideoSourceConfiguration.SourceToken + if processed[token] { + continue + } + processed[token] = true + + start := time.Now() + result := ImagingSettingsResult{ + VideoSourceToken: token, + } + + settings, err := client.GetImagingSettings(ctx, token) + result.ResponseTime = time.Since(start).String() + + if err != nil { + result.Success = false + result.Error = err.Error() + if *verbose { + logError(" Video source %s: %v", token, err) + } + report.Errors = append(report.Errors, ErrorLog{ + Operation: fmt.Sprintf("GetImagingSettings[%s]", token), + Error: err.Error(), + Timestamp: time.Now().Format(time.RFC3339), + }) + } else { + result.Success = true + result.Data = settings + if *verbose { + fmt.Printf(" ✓ Video source %s: Retrieved\n", token) + } + } + + results = append(results, result) + } + + successCount := 0 + for _, r := range results { + if r.Success { + successCount++ + } + } + logSuccess("Retrieved %d/%d imaging settings", successCount, len(results)) + + return results +} + +func testGetPTZStatus(ctx context.Context, client *onvif.Client, profiles []*onvif.Profile, report *CameraReport) []PTZStatusResult { + results := make([]PTZStatusResult, 0) + + for _, profile := range profiles { + if profile.PTZConfiguration == nil { + continue + } + + start := time.Now() + result := PTZStatusResult{ + ProfileToken: profile.Token, + ProfileName: profile.Name, + } + + status, err := client.GetStatus(ctx, profile.Token) + result.ResponseTime = time.Since(start).String() + + if err != nil { + result.Success = false + result.Error = err.Error() + if *verbose { + logError(" Profile %s: %v", profile.Name, err) + } + report.Errors = append(report.Errors, ErrorLog{ + Operation: fmt.Sprintf("GetPTZStatus[%s]", profile.Token), + Error: err.Error(), + Timestamp: time.Now().Format(time.RFC3339), + }) + } else { + result.Success = true + result.Data = status + if *verbose { + logSuccess(" Profile %s: Retrieved", profile.Name) + } + } + + results = append(results, result) + } + + if len(results) == 0 { + logInfo("No PTZ configurations found") + } else { + successCount := 0 + for _, r := range results { + if r.Success { + successCount++ + } + } + logSuccess("Retrieved %d/%d PTZ status", successCount, len(results)) + } + + return results +} + +func testGetPTZPresets(ctx context.Context, client *onvif.Client, profiles []*onvif.Profile, report *CameraReport) []PTZPresetsResult { + results := make([]PTZPresetsResult, 0) + + for _, profile := range profiles { + if profile.PTZConfiguration == nil { + continue + } + + start := time.Now() + result := PTZPresetsResult{ + ProfileToken: profile.Token, + ProfileName: profile.Name, + } + + presets, err := client.GetPresets(ctx, profile.Token) + result.ResponseTime = time.Since(start).String() + + if err != nil { + result.Success = false + result.Error = err.Error() + if *verbose { + logError(" Profile %s: %v", profile.Name, err) + } + report.Errors = append(report.Errors, ErrorLog{ + Operation: fmt.Sprintf("GetPTZPresets[%s]", profile.Token), + Error: err.Error(), + Timestamp: time.Now().Format(time.RFC3339), + }) + } else { + result.Success = true + result.Data = presets + result.Count = len(presets) + if *verbose { + logSuccess(" Profile %s: %d preset(s)", profile.Name, len(presets)) + } + } + + results = append(results, result) + } + + if len(results) == 0 { + logInfo("No PTZ configurations found") + } else { + successCount := 0 + totalPresets := 0 + for _, r := range results { + if r.Success { + successCount++ + totalPresets += r.Count + } + } + logSuccess("Retrieved presets from %d/%d PTZ profiles (%d total presets)", successCount, len(results), totalPresets) + } + + return results +} + +func generateFilename(report *CameraReport) string { + timestamp := time.Now().Format("20060102-150405") + + if report.DeviceInfo != nil && report.DeviceInfo.Success { + manufacturer := sanitizeFilename(report.DeviceInfo.Data.Manufacturer) + model := sanitizeFilename(report.DeviceInfo.Data.Model) + firmware := sanitizeFilename(report.DeviceInfo.Data.FirmwareVersion) + + return fmt.Sprintf("%s_%s_%s_%s.json", manufacturer, model, firmware, timestamp) + } + + return fmt.Sprintf("unknown_camera_%s.json", timestamp) +} + +func sanitizeFilename(s string) string { + s = strings.ReplaceAll(s, " ", "_") + s = strings.ReplaceAll(s, "/", "-") + s = strings.ReplaceAll(s, "\\", "-") + s = strings.ReplaceAll(s, ":", "-") + s = strings.ReplaceAll(s, "*", "-") + s = strings.ReplaceAll(s, "?", "-") + s = strings.ReplaceAll(s, "\"", "-") + s = strings.ReplaceAll(s, "<", "-") + s = strings.ReplaceAll(s, ">", "-") + s = strings.ReplaceAll(s, "|", "-") + return s +} + +func saveReport(report *CameraReport, filename string) error { + data, err := json.MarshalIndent(report, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal report: %w", err) + } + + if err := os.WriteFile(filename, data, 0644); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} + +func logStep(format string, args ...interface{}) { + fmt.Printf("→ "+format+"\n", args...) +} + +func logSuccess(format string, args ...interface{}) { + fmt.Printf(" ✓ "+format+"\n", args...) +} + +func logError(format string, args ...interface{}) { + fmt.Printf(" ✗ "+format+"\n", args...) +} + +func logInfo(format string, args ...interface{}) { + fmt.Printf(" ℹ "+format+"\n", args...) +} + +// XML Capture functionality + +// XMLCapture stores a request/response pair +type XMLCapture struct { + Timestamp string `json:"timestamp"` + Operation int `json:"operation"` + 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"` +} + +// LoggingTransport wraps http.RoundTripper to log requests and responses +type LoggingTransport struct { + Transport http.RoundTripper + LogDir string + Counter int +} + +func (t *LoggingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + t.Counter++ + capture := XMLCapture{ + Timestamp: time.Now().Format(time.RFC3339), + Operation: t.Counter, + Endpoint: req.URL.String(), + } + + // Capture request body + if req.Body != nil { + bodyBytes, err := io.ReadAll(req.Body) + if err == nil { + capture.RequestBody = string(bodyBytes) + // Extract operation name from SOAP body + capture.OperationName = extractSOAPOperation(capture.RequestBody) + // Restore the body for the actual request + req.Body = io.NopCloser(strings.NewReader(string(bodyBytes))) + } + } + + // Make the actual request + resp, err := t.Transport.RoundTrip(req) + if err != nil { + capture.Error = err.Error() + t.saveCapture(capture) + return nil, err + } + + // Capture response + capture.StatusCode = resp.StatusCode + if resp.Body != nil { + bodyBytes, err := io.ReadAll(resp.Body) + if err == nil { + capture.ResponseBody = string(bodyBytes) + // Restore the body for the caller + resp.Body = io.NopCloser(strings.NewReader(string(bodyBytes))) + } + } + + t.saveCapture(capture) + return resp, nil +} + +// prettyPrintXML formats XML with proper indentation using a simple algorithm +func prettyPrintXML(xmlStr string) string { + if xmlStr == "" { + return "" + } + + var formatted bytes.Buffer + decoder := xml.NewDecoder(strings.NewReader(xmlStr)) + encoder := xml.NewEncoder(&formatted) + encoder.Indent("", " ") + + for { + token, err := decoder.Token() + if err != nil { + if err.Error() == "EOF" { + break + } + // If formatting fails, return original + return xmlStr + } + + if err := encoder.EncodeToken(token); err != nil { + return xmlStr + } + } + + if err := encoder.Flush(); err != nil { + return xmlStr + } + + return formatted.String() +} + +func (t *LoggingTransport) saveCapture(capture XMLCapture) { + // Create filename base using operation name + baseFilename := fmt.Sprintf("capture_%03d_%s", capture.Operation, capture.OperationName) + + // Save as individual JSON file + filename := filepath.Join(t.LogDir, baseFilename+".json") + data, err := json.MarshalIndent(capture, "", " ") + if err != nil { + log.Printf("Failed to marshal capture: %v", err) + return + } + + if err := os.WriteFile(filename, data, 0644); err != nil { + log.Printf("Failed to write capture: %v", err) + } + + // Pretty-print and save XML files for easier viewing + reqFile := filepath.Join(t.LogDir, baseFilename+"_request.xml") + prettyRequest := prettyPrintXML(capture.RequestBody) + if err := os.WriteFile(reqFile, []byte(prettyRequest), 0644); err != nil { + log.Printf("Failed to write request XML: %v", err) + } + + respFile := filepath.Join(t.LogDir, baseFilename+"_response.xml") + prettyResponse := prettyPrintXML(capture.ResponseBody) + if err := os.WriteFile(respFile, []byte(prettyResponse), 0644); err != nil { + log.Printf("Failed to write response XML: %v", err) + } +} + +// extractSOAPOperation extracts the operation name from a SOAP request body +func extractSOAPOperation(soapBody string) string { + // Look for the operation element in the SOAP Body + // Typical format: ... + + // Find the Body element + bodyStart := strings.Index(soapBody, " of the Body opening tag + bodyOpenEnd := strings.Index(soapBody[bodyStart:], ">") + if bodyOpenEnd == -1 { + return "Unknown" + } + bodyContentStart := bodyStart + bodyOpenEnd + 1 + + // Find the first element after + // Skip whitespace and find next < + for bodyContentStart < len(soapBody) && soapBody[bodyContentStart] <= ' ' { + bodyContentStart++ + } + + if bodyContentStart >= len(soapBody) || soapBody[bodyContentStart] != '<' { + return "Unknown" + } + + // Extract the tag name + tagStart := bodyContentStart + 1 + tagEnd := tagStart + for tagEnd < len(soapBody) && soapBody[tagEnd] != ' ' && soapBody[tagEnd] != '>' && soapBody[tagEnd] != '/' { + tagEnd++ + } + + if tagEnd > tagStart { + tagName := soapBody[tagStart:tagEnd] + // Remove namespace prefix if present (e.g., "tds:GetDeviceInformation" -> "GetDeviceInformation") + if colonIdx := strings.Index(tagName, ":"); colonIdx != -1 { + return tagName[colonIdx+1:] + } + return tagName + } + + return "Unknown" +} + +// createTarGz creates a tar.gz archive from a directory +func createTarGz(sourceDir, archivePath string) error { + // Create archive file + archiveFile, err := os.Create(archivePath) + if err != nil { + return fmt.Errorf("failed to create archive file: %w", err) + } + defer archiveFile.Close() + + // Create gzip writer + gzWriter := gzip.NewWriter(archiveFile) + defer gzWriter.Close() + + // Create tar writer + tarWriter := tar.NewWriter(gzWriter) + defer tarWriter.Close() + + // Walk through source directory + return 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 { + return nil + } + + // Create tar header + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return fmt.Errorf("failed to create tar header: %w", err) + } + + // Set name to relative path + relPath, err := filepath.Rel(sourceDir, path) + if err != nil { + return fmt.Errorf("failed to get relative path: %w", err) + } + header.Name = relPath + + // Write header + if err := tarWriter.WriteHeader(header); err != nil { + 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) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + if _, err := io.Copy(tarWriter, file); err != nil { + return fmt.Errorf("failed to write file to tar: %w", err) + } + } + + return nil + }) +}