bfad9e910c
- Added new linters for the examples directory to improve code quality checks. - Updated output formatting to direct lint results to stdout for better visibility. - Cleaned up comments related to error handling in various files for clarity and consistency.
1171 lines
33 KiB
Go
1171 lines
33 KiB
Go
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"
|
||
defaultTimeoutSec = 30
|
||
maxRetryAttempts = 10
|
||
retryDelaySec = 5
|
||
maxIdleTimeoutSec = 90
|
||
unknownStatus = "Unknown"
|
||
)
|
||
|
||
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") //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")
|
||
)
|
||
|
||
//nolint:funlen,gocognit,gocyclo // Main function has high complexity due to multiple diagnostic operations
|
||
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, 0750); err != nil { //nolint:mnd // 0750 appropriate for diagnostic output
|
||
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, 0750); err != nil { //nolint:mnd // 0750 appropriate for diagnostic output
|
||
log.Fatalf("Failed to create XML capture directory: %v", err)
|
||
}
|
||
|
||
loggingTransport = &LoggingTransport{
|
||
Transport: &http.Transport{
|
||
MaxIdleConns: maxRetryAttempts,
|
||
MaxIdleConnsPerHost: retryDelaySec,
|
||
IdleConnTimeout: maxIdleTimeoutSec * 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
|
||
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),
|
||
})
|
||
} else {
|
||
logSuccessf("Service endpoints discovered")
|
||
}
|
||
|
||
// Test 5: Get Profiles
|
||
logStepf("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 {
|
||
logStepf("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 {
|
||
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
|
||
filename := generateFilename(report)
|
||
outputPath := filepath.Join(*outputDir, filename)
|
||
|
||
// Save report
|
||
logStepf("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()
|
||
logStepf("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 {
|
||
logErrorf("Failed to create XML archive: %v", err)
|
||
} else {
|
||
logSuccessf("XML archive created: %s", archiveName)
|
||
logSuccessf("Total SOAP calls captured: %d", loggingTransport.Counter)
|
||
|
||
// Remove temporary directory
|
||
if err := os.RemoveAll(xmlCaptureDir); err != nil {
|
||
logErrorf("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()
|
||
logErrorf("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
|
||
logSuccessf("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()
|
||
logErrorf("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
|
||
logSuccessf("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()
|
||
logErrorf("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")
|
||
}
|
||
|
||
logSuccessf("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()
|
||
logErrorf("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)
|
||
logSuccessf("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 {
|
||
logErrorf(" 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 {
|
||
logSuccessf(" Profile %s: %s", profile.Name, streamURI.URI)
|
||
}
|
||
}
|
||
|
||
results = append(results, result)
|
||
}
|
||
|
||
successCount := 0
|
||
for _, r := range results {
|
||
if r.Success {
|
||
successCount++
|
||
}
|
||
}
|
||
logSuccessf("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 {
|
||
logErrorf(" 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 {
|
||
logSuccessf(" Profile %s: %s", profile.Name, snapshotURI.URI)
|
||
}
|
||
}
|
||
|
||
results = append(results, result)
|
||
}
|
||
|
||
successCount := 0
|
||
for _, r := range results {
|
||
if r.Success {
|
||
successCount++
|
||
}
|
||
}
|
||
logSuccessf("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 {
|
||
logErrorf(" 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 {
|
||
logSuccessf(" 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++
|
||
}
|
||
}
|
||
logSuccessf("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 {
|
||
logErrorf(" 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++
|
||
}
|
||
}
|
||
logSuccessf("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 {
|
||
logErrorf(" 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 {
|
||
logSuccessf(" Profile %s: Retrieved", profile.Name)
|
||
}
|
||
}
|
||
|
||
results = append(results, result)
|
||
}
|
||
|
||
if len(results) == 0 {
|
||
logInfof("No PTZ configurations found")
|
||
} else {
|
||
successCount := 0
|
||
for _, r := range results {
|
||
if r.Success {
|
||
successCount++
|
||
}
|
||
}
|
||
logSuccessf("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 {
|
||
logErrorf(" 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 {
|
||
logSuccessf(" Profile %s: %d preset(s)", profile.Name, len(presets))
|
||
}
|
||
}
|
||
|
||
results = append(results, result)
|
||
}
|
||
|
||
if len(results) == 0 {
|
||
logInfof("No PTZ configurations found")
|
||
} else {
|
||
successCount := 0
|
||
totalPresets := 0
|
||
for _, r := range results {
|
||
if r.Success {
|
||
successCount++
|
||
totalPresets += r.Count
|
||
}
|
||
}
|
||
logSuccessf("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, 0600); err != nil { //nolint:mnd // 0600 appropriate for diagnostic files
|
||
return fmt.Errorf("failed to write file: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
//nolint:unparam // args parameter is kept for printf-style consistency, even though currently unused
|
||
func logStepf(format string, args ...interface{}) {
|
||
if len(args) > 0 {
|
||
fmt.Printf("→ %s\n", fmt.Sprintf(format, args...))
|
||
} else {
|
||
fmt.Printf("→ %s\n", format)
|
||
}
|
||
}
|
||
|
||
func logSuccessf(format string, args ...interface{}) {
|
||
fmt.Printf(" ✓ %s\n", fmt.Sprintf(format, args...))
|
||
}
|
||
|
||
func logErrorf(format string, args ...interface{}) {
|
||
fmt.Printf(" ✗ %s\n", fmt.Sprintf(format, args...))
|
||
}
|
||
|
||
func logInfof(format string, args ...interface{}) {
|
||
fmt.Printf(" ℹ %s\n", fmt.Sprintf(format, 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, fmt.Errorf("round trip failed: %w", 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, 0600); err != nil { //nolint:mnd // 0600 appropriate for diagnostic files
|
||
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), 0600, //nolint:mnd // 0600 appropriate for diagnostic files
|
||
); 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), 0600, //nolint:mnd // 0600 appropriate for diagnostic files
|
||
); 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: <Body><GetDeviceInformation xmlns="...">...</GetDeviceInformation></Body>
|
||
|
||
// Find the Body element
|
||
bodyStart := strings.Index(soapBody, "<Body")
|
||
if bodyStart == -1 {
|
||
return unknownStatus
|
||
}
|
||
|
||
// Find the closing > of the Body opening tag
|
||
bodyOpenEnd := strings.Index(soapBody[bodyStart:], ">")
|
||
if bodyOpenEnd == -1 {
|
||
return unknownStatus
|
||
}
|
||
bodyContentStart := bodyStart + bodyOpenEnd + 1
|
||
|
||
// Find the first element after <Body>
|
||
// Skip whitespace and find next <
|
||
for bodyContentStart < len(soapBody) && soapBody[bodyContentStart] <= ' ' {
|
||
bodyContentStart++
|
||
}
|
||
|
||
if bodyContentStart >= len(soapBody) || soapBody[bodyContentStart] != '<' {
|
||
return unknownStatus
|
||
}
|
||
|
||
// 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) //nolint:gosec // Archive path is validated before use
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create archive file: %w", err)
|
||
}
|
||
defer func() {
|
||
_ = archiveFile.Close()
|
||
}()
|
||
|
||
// Create gzip writer
|
||
gzWriter := gzip.NewWriter(archiveFile)
|
||
defer func() {
|
||
_ = gzWriter.Close()
|
||
}()
|
||
|
||
// Create tar writer
|
||
tarWriter := tar.NewWriter(gzWriter)
|
||
defer func() {
|
||
_ = tarWriter.Close()
|
||
}()
|
||
|
||
// Walk through source directory
|
||
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 {
|
||
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) //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)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}); err != nil {
|
||
return fmt.Errorf("failed to walk source directory: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|