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