cleanup
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
// Package onviftesting provides testing utilities for ONVIF client testing.
|
||||
package onviftesting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CaptureVersion is the current capture format version.
|
||||
const CaptureVersion = "2.0"
|
||||
|
||||
// ServiceType categorizes ONVIF services.
|
||||
type ServiceType string
|
||||
|
||||
const (
|
||||
ServiceDevice ServiceType = "Device"
|
||||
ServiceMedia ServiceType = "Media"
|
||||
ServicePTZ ServiceType = "PTZ"
|
||||
ServiceImaging ServiceType = "Imaging"
|
||||
ServiceEvent ServiceType = "Event"
|
||||
ServiceDeviceIO ServiceType = "DeviceIO"
|
||||
ServiceUnknown ServiceType = "Unknown"
|
||||
)
|
||||
|
||||
// CameraInfo stores camera identification information.
|
||||
type CameraInfo struct {
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Model string `json:"model"`
|
||||
FirmwareVersion string `json:"firmware_version"`
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
HardwareID string `json:"hardware_id,omitempty"`
|
||||
}
|
||||
|
||||
// CaptureMetadata contains versioned capture archive metadata.
|
||||
// This is stored as metadata.json in V2 archives.
|
||||
type CaptureMetadata struct {
|
||||
Version string `json:"version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ToolVersion string `json:"tool_version"`
|
||||
CameraInfo CameraInfo `json:"camera_info"`
|
||||
TotalExchanges int `json:"total_exchanges"`
|
||||
ServiceMap map[string]string `json:"service_map,omitempty"` // operation -> service type
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// CapturedExchangeV2 extends the original CapturedExchange with parameter awareness
|
||||
// and additional metadata for smarter request matching.
|
||||
type CapturedExchangeV2 struct {
|
||||
// Version indicates the capture format version (empty for V1, "2.0" for V2)
|
||||
Version string `json:"version,omitempty"`
|
||||
|
||||
// Timestamp is when the exchange was captured (RFC3339 format)
|
||||
Timestamp string `json:"timestamp"`
|
||||
|
||||
// Sequence is the capture order (1-indexed for V2, 0-indexed for V1)
|
||||
Sequence int `json:"sequence,omitempty"`
|
||||
|
||||
// Operation is deprecated in V2, kept for V1 compatibility
|
||||
Operation int `json:"operation,omitempty"`
|
||||
|
||||
// OperationName is the SOAP operation name (e.g., "GetDeviceInformation")
|
||||
OperationName string `json:"operation_name,omitempty"`
|
||||
|
||||
// ServiceType categorizes which ONVIF service handles this operation
|
||||
ServiceType ServiceType `json:"service_type,omitempty"`
|
||||
|
||||
// Parameters contains extracted key parameters from the request
|
||||
// Common keys: ProfileToken, ConfigurationToken, VideoSourceToken, etc.
|
||||
Parameters map[string]interface{} `json:"parameters,omitempty"`
|
||||
|
||||
// Endpoint is the URL the request was sent to
|
||||
Endpoint string `json:"endpoint"`
|
||||
|
||||
// RequestBody is the full SOAP request XML
|
||||
RequestBody string `json:"request_body"`
|
||||
|
||||
// ResponseBody is the full SOAP response XML
|
||||
ResponseBody string `json:"response_body"`
|
||||
|
||||
// StatusCode is the HTTP response status code
|
||||
StatusCode int `json:"status_code"`
|
||||
|
||||
// DurationNs is the request duration in nanoseconds
|
||||
DurationNs int64 `json:"duration_ns,omitempty"`
|
||||
|
||||
// Success indicates if the operation succeeded (no SOAP fault)
|
||||
Success bool `json:"success,omitempty"`
|
||||
|
||||
// Error contains error message if the operation failed
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// IsV2 returns true if this exchange is in V2 format.
|
||||
func (e *CapturedExchangeV2) IsV2() bool {
|
||||
return e.Version != "" && e.Version >= "2.0"
|
||||
}
|
||||
|
||||
// GetProfileToken returns the ProfileToken parameter if present.
|
||||
func (e *CapturedExchangeV2) GetProfileToken() string {
|
||||
if e.Parameters == nil {
|
||||
return ""
|
||||
}
|
||||
if token, ok := e.Parameters["ProfileToken"].(string); ok {
|
||||
return token
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetConfigurationToken returns the ConfigurationToken parameter if present.
|
||||
func (e *CapturedExchangeV2) GetConfigurationToken() string {
|
||||
if e.Parameters == nil {
|
||||
return ""
|
||||
}
|
||||
if token, ok := e.Parameters["ConfigurationToken"].(string); ok {
|
||||
return token
|
||||
}
|
||||
// Also check for Token (some operations use just "Token")
|
||||
if token, ok := e.Parameters["Token"].(string); ok {
|
||||
return token
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetVideoSourceToken returns the VideoSourceToken parameter if present.
|
||||
func (e *CapturedExchangeV2) GetVideoSourceToken() string {
|
||||
if e.Parameters == nil {
|
||||
return ""
|
||||
}
|
||||
if token, ok := e.Parameters["VideoSourceToken"].(string); ok {
|
||||
return token
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetAudioSourceToken returns the AudioSourceToken parameter if present.
|
||||
func (e *CapturedExchangeV2) GetAudioSourceToken() string {
|
||||
if e.Parameters == nil {
|
||||
return ""
|
||||
}
|
||||
if token, ok := e.Parameters["AudioSourceToken"].(string); ok {
|
||||
return token
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetPresetToken returns the PresetToken parameter if present.
|
||||
func (e *CapturedExchangeV2) GetPresetToken() string {
|
||||
if e.Parameters == nil {
|
||||
return ""
|
||||
}
|
||||
if token, ok := e.Parameters["PresetToken"].(string); ok {
|
||||
return token
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetNodeToken returns the NodeToken parameter if present.
|
||||
func (e *CapturedExchangeV2) GetNodeToken() string {
|
||||
if e.Parameters == nil {
|
||||
return ""
|
||||
}
|
||||
if token, ok := e.Parameters["NodeToken"].(string); ok {
|
||||
return token
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetOSDToken returns the OSDToken parameter if present.
|
||||
func (e *CapturedExchangeV2) GetOSDToken() string {
|
||||
if e.Parameters == nil {
|
||||
return ""
|
||||
}
|
||||
if token, ok := e.Parameters["OSDToken"].(string); ok {
|
||||
return token
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CameraCaptureV2 holds all captured exchanges for a camera with metadata.
|
||||
type CameraCaptureV2 struct {
|
||||
Metadata *CaptureMetadata `json:"metadata,omitempty"`
|
||||
Exchanges []CapturedExchangeV2 `json:"exchanges"`
|
||||
}
|
||||
|
||||
// MatchKey uniquely identifies a capture for parameter-aware matching.
|
||||
type MatchKey struct {
|
||||
OperationName string
|
||||
ProfileToken string
|
||||
ConfigurationToken string
|
||||
VideoSourceToken string
|
||||
// Extended fields for better matching
|
||||
AudioSourceToken string
|
||||
PresetToken string
|
||||
NodeToken string
|
||||
OSDToken string
|
||||
}
|
||||
|
||||
// String returns a string representation of the match key for debugging.
|
||||
func (k MatchKey) String() string {
|
||||
s := k.OperationName
|
||||
if k.ProfileToken != "" {
|
||||
s += "[Profile:" + k.ProfileToken + "]"
|
||||
}
|
||||
if k.ConfigurationToken != "" {
|
||||
s += "[Config:" + k.ConfigurationToken + "]"
|
||||
}
|
||||
if k.VideoSourceToken != "" {
|
||||
s += "[VideoSource:" + k.VideoSourceToken + "]"
|
||||
}
|
||||
if k.AudioSourceToken != "" {
|
||||
s += "[AudioSource:" + k.AudioSourceToken + "]"
|
||||
}
|
||||
if k.PresetToken != "" {
|
||||
s += "[Preset:" + k.PresetToken + "]"
|
||||
}
|
||||
if k.NodeToken != "" {
|
||||
s += "[Node:" + k.NodeToken + "]"
|
||||
}
|
||||
if k.OSDToken != "" {
|
||||
s += "[OSD:" + k.OSDToken + "]"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// BuildMatchKey creates a MatchKey from an operation name and parameters.
|
||||
func BuildMatchKey(operationName string, params map[string]interface{}) MatchKey {
|
||||
key := MatchKey{
|
||||
OperationName: operationName,
|
||||
}
|
||||
|
||||
if params == nil {
|
||||
return key
|
||||
}
|
||||
|
||||
if token, ok := params["ProfileToken"].(string); ok {
|
||||
key.ProfileToken = token
|
||||
}
|
||||
if token, ok := params["ConfigurationToken"].(string); ok {
|
||||
key.ConfigurationToken = token
|
||||
} else if token, ok := params["Token"].(string); ok {
|
||||
key.ConfigurationToken = token
|
||||
}
|
||||
if token, ok := params["VideoSourceToken"].(string); ok {
|
||||
key.VideoSourceToken = token
|
||||
}
|
||||
if token, ok := params["AudioSourceToken"].(string); ok {
|
||||
key.AudioSourceToken = token
|
||||
}
|
||||
if token, ok := params["PresetToken"].(string); ok {
|
||||
key.PresetToken = token
|
||||
}
|
||||
if token, ok := params["NodeToken"].(string); ok {
|
||||
key.NodeToken = token
|
||||
}
|
||||
if token, ok := params["OSDToken"].(string); ok {
|
||||
key.OSDToken = token
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
// BuildMatchKeyFromExchange creates a MatchKey from a captured exchange.
|
||||
func BuildMatchKeyFromExchange(exchange *CapturedExchangeV2) MatchKey {
|
||||
return MatchKey{
|
||||
OperationName: exchange.OperationName,
|
||||
ProfileToken: exchange.GetProfileToken(),
|
||||
ConfigurationToken: exchange.GetConfigurationToken(),
|
||||
VideoSourceToken: exchange.GetVideoSourceToken(),
|
||||
AudioSourceToken: exchange.GetAudioSourceToken(),
|
||||
PresetToken: exchange.GetPresetToken(),
|
||||
NodeToken: exchange.GetNodeToken(),
|
||||
OSDToken: exchange.GetOSDToken(),
|
||||
}
|
||||
}
|
||||
|
||||
// MatchScore returns how well two MatchKeys match (higher is better).
|
||||
// Returns -1 if operation names don't match.
|
||||
func (k MatchKey) MatchScore(other MatchKey) int {
|
||||
if k.OperationName != other.OperationName {
|
||||
return -1
|
||||
}
|
||||
|
||||
score := 1 // Base score for matching operation
|
||||
|
||||
// Bonus points for matching parameters
|
||||
if k.ProfileToken != "" && k.ProfileToken == other.ProfileToken {
|
||||
score += 10
|
||||
}
|
||||
if k.ConfigurationToken != "" && k.ConfigurationToken == other.ConfigurationToken {
|
||||
score += 10
|
||||
}
|
||||
if k.VideoSourceToken != "" && k.VideoSourceToken == other.VideoSourceToken {
|
||||
score += 10
|
||||
}
|
||||
if k.AudioSourceToken != "" && k.AudioSourceToken == other.AudioSourceToken {
|
||||
score += 10
|
||||
}
|
||||
if k.PresetToken != "" && k.PresetToken == other.PresetToken {
|
||||
score += 10
|
||||
}
|
||||
if k.NodeToken != "" && k.NodeToken == other.NodeToken {
|
||||
score += 10
|
||||
}
|
||||
if k.OSDToken != "" && k.OSDToken == other.OSDToken {
|
||||
score += 10
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
// DetectCaptureVersion determines if JSON data is V1 or V2 format.
|
||||
func DetectCaptureVersion(data []byte) string {
|
||||
var probe struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &probe); err != nil {
|
||||
return "1.0"
|
||||
}
|
||||
if probe.Version == "" {
|
||||
return "1.0"
|
||||
}
|
||||
return probe.Version
|
||||
}
|
||||
|
||||
// ConvertV1ToV2 converts a V1 CapturedExchange to V2 format.
|
||||
func ConvertV1ToV2(v1 *CapturedExchange) *CapturedExchangeV2 {
|
||||
return &CapturedExchangeV2{
|
||||
Version: "", // Keep empty to indicate V1 origin
|
||||
Timestamp: v1.Timestamp,
|
||||
Operation: v1.Operation,
|
||||
OperationName: v1.OperationName,
|
||||
Endpoint: v1.Endpoint,
|
||||
RequestBody: v1.RequestBody,
|
||||
ResponseBody: v1.ResponseBody,
|
||||
StatusCode: v1.StatusCode,
|
||||
Error: v1.Error,
|
||||
Success: v1.StatusCode >= 200 && v1.StatusCode < 300 && v1.Error == "",
|
||||
}
|
||||
}
|
||||
|
||||
// serviceNamespaces maps ONVIF service namespaces to ServiceType.
|
||||
var serviceNamespaces = map[string]ServiceType{
|
||||
"http://www.onvif.org/ver10/device/wsdl": ServiceDevice,
|
||||
"http://www.onvif.org/ver10/media/wsdl": ServiceMedia,
|
||||
"http://www.onvif.org/ver20/media/wsdl": ServiceMedia,
|
||||
"http://www.onvif.org/ver20/ptz/wsdl": ServicePTZ,
|
||||
"http://www.onvif.org/ver10/ptz/wsdl": ServicePTZ,
|
||||
"http://www.onvif.org/ver20/imaging/wsdl": ServiceImaging,
|
||||
"http://www.onvif.org/ver10/imaging/wsdl": ServiceImaging,
|
||||
"http://www.onvif.org/ver10/events/wsdl": ServiceEvent,
|
||||
"http://www.onvif.org/ver10/deviceIO/wsdl": ServiceDeviceIO,
|
||||
}
|
||||
|
||||
// DetermineServiceType determines the service type from a SOAP request body.
|
||||
func DetermineServiceType(soapBody string) ServiceType {
|
||||
for ns, svc := range serviceNamespaces {
|
||||
if containsString(soapBody, ns) {
|
||||
return svc
|
||||
}
|
||||
}
|
||||
return ServiceUnknown
|
||||
}
|
||||
|
||||
// containsString is a simple string contains check.
|
||||
func containsString(s, substr string) bool {
|
||||
return len(s) >= len(substr) && findString(s, substr) >= 0
|
||||
}
|
||||
|
||||
// findString finds substr in s, returns -1 if not found.
|
||||
func findString(s, substr string) int {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package onviftesting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectCaptureVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "V1 format (no version)",
|
||||
input: `{"timestamp":"2025-01-01T00:00:00Z","operation":1}`,
|
||||
expected: "1.0",
|
||||
},
|
||||
{
|
||||
name: "V2 format",
|
||||
input: `{"version":"2.0","timestamp":"2025-01-01T00:00:00Z"}`,
|
||||
expected: "2.0",
|
||||
},
|
||||
{
|
||||
name: "Empty object",
|
||||
input: `{}`,
|
||||
expected: "1.0",
|
||||
},
|
||||
{
|
||||
name: "Invalid JSON",
|
||||
input: `{invalid}`,
|
||||
expected: "1.0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := DetectCaptureVersion([]byte(tt.input))
|
||||
if result != tt.expected {
|
||||
t.Errorf("DetectCaptureVersion() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapturedExchangeV2_IsV2(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
exchange CapturedExchangeV2
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "V2 exchange",
|
||||
exchange: CapturedExchangeV2{Version: "2.0"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "V1 exchange (empty version)",
|
||||
exchange: CapturedExchangeV2{Version: ""},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if result := tt.exchange.IsV2(); result != tt.expected {
|
||||
t.Errorf("IsV2() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapturedExchangeV2_GetTokens(t *testing.T) {
|
||||
exchange := CapturedExchangeV2{
|
||||
Parameters: map[string]interface{}{
|
||||
"ProfileToken": "profile1",
|
||||
"ConfigurationToken": "config1",
|
||||
"VideoSourceToken": "video1",
|
||||
},
|
||||
}
|
||||
|
||||
if token := exchange.GetProfileToken(); token != "profile1" {
|
||||
t.Errorf("GetProfileToken() = %v, want %v", token, "profile1")
|
||||
}
|
||||
|
||||
if token := exchange.GetConfigurationToken(); token != "config1" {
|
||||
t.Errorf("GetConfigurationToken() = %v, want %v", token, "config1")
|
||||
}
|
||||
|
||||
if token := exchange.GetVideoSourceToken(); token != "video1" {
|
||||
t.Errorf("GetVideoSourceToken() = %v, want %v", token, "video1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapturedExchangeV2_GetTokens_Empty(t *testing.T) {
|
||||
exchange := CapturedExchangeV2{}
|
||||
|
||||
if token := exchange.GetProfileToken(); token != "" {
|
||||
t.Errorf("GetProfileToken() should return empty string for nil parameters")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMatchKey(t *testing.T) {
|
||||
params := map[string]interface{}{
|
||||
"ProfileToken": "profile1",
|
||||
"ConfigurationToken": "config1",
|
||||
}
|
||||
|
||||
key := BuildMatchKey("GetStreamURI", params)
|
||||
|
||||
if key.OperationName != "GetStreamURI" {
|
||||
t.Errorf("OperationName = %v, want %v", key.OperationName, "GetStreamURI")
|
||||
}
|
||||
|
||||
if key.ProfileToken != "profile1" {
|
||||
t.Errorf("ProfileToken = %v, want %v", key.ProfileToken, "profile1")
|
||||
}
|
||||
|
||||
if key.ConfigurationToken != "config1" {
|
||||
t.Errorf("ConfigurationToken = %v, want %v", key.ConfigurationToken, "config1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchKey_MatchScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key1 MatchKey
|
||||
key2 MatchKey
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "Different operations",
|
||||
key1: MatchKey{OperationName: "GetProfiles"},
|
||||
key2: MatchKey{OperationName: "GetStreamURI"},
|
||||
expected: -1,
|
||||
},
|
||||
{
|
||||
name: "Same operation only",
|
||||
key1: MatchKey{OperationName: "GetProfiles"},
|
||||
key2: MatchKey{OperationName: "GetProfiles"},
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
name: "Same operation with matching profile",
|
||||
key1: MatchKey{OperationName: "GetStreamURI", ProfileToken: "profile1"},
|
||||
key2: MatchKey{OperationName: "GetStreamURI", ProfileToken: "profile1"},
|
||||
expected: 11, // 1 + 10
|
||||
},
|
||||
{
|
||||
name: "Same operation with non-matching profile",
|
||||
key1: MatchKey{OperationName: "GetStreamURI", ProfileToken: "profile1"},
|
||||
key2: MatchKey{OperationName: "GetStreamURI", ProfileToken: "profile2"},
|
||||
expected: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if result := tt.key1.MatchScore(tt.key2); result != tt.expected {
|
||||
t.Errorf("MatchScore() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetermineServiceType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
soapBody string
|
||||
expected ServiceType
|
||||
}{
|
||||
{
|
||||
name: "Device service",
|
||||
soapBody: `xmlns="http://www.onvif.org/ver10/device/wsdl"`,
|
||||
expected: ServiceDevice,
|
||||
},
|
||||
{
|
||||
name: "Media service",
|
||||
soapBody: `xmlns="http://www.onvif.org/ver10/media/wsdl"`,
|
||||
expected: ServiceMedia,
|
||||
},
|
||||
{
|
||||
name: "PTZ service",
|
||||
soapBody: `xmlns="http://www.onvif.org/ver20/ptz/wsdl"`,
|
||||
expected: ServicePTZ,
|
||||
},
|
||||
{
|
||||
name: "Unknown namespace",
|
||||
soapBody: `xmlns="http://example.com/unknown"`,
|
||||
expected: ServiceUnknown,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if result := DetermineServiceType(tt.soapBody); result != tt.expected {
|
||||
t.Errorf("DetermineServiceType() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertV1ToV2(t *testing.T) {
|
||||
v1 := &CapturedExchange{
|
||||
Timestamp: "2025-01-01T00:00:00Z",
|
||||
Operation: 1,
|
||||
OperationName: "GetDeviceInformation",
|
||||
Endpoint: "http://camera/onvif/device_service",
|
||||
RequestBody: "<request/>",
|
||||
ResponseBody: "<response/>",
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
v2 := ConvertV1ToV2(v1)
|
||||
|
||||
if v2.Version != "" {
|
||||
t.Errorf("Version should be empty for converted V1, got %v", v2.Version)
|
||||
}
|
||||
|
||||
if v2.OperationName != v1.OperationName {
|
||||
t.Errorf("OperationName = %v, want %v", v2.OperationName, v1.OperationName)
|
||||
}
|
||||
|
||||
if v2.StatusCode != v1.StatusCode {
|
||||
t.Errorf("StatusCode = %v, want %v", v2.StatusCode, v1.StatusCode)
|
||||
}
|
||||
|
||||
if !v2.Success {
|
||||
t.Errorf("Success should be true for 200 status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureMetadata_JSON(t *testing.T) {
|
||||
metadata := CaptureMetadata{
|
||||
Version: CaptureVersion,
|
||||
ToolVersion: "1.0.0",
|
||||
CameraInfo: CameraInfo{
|
||||
Manufacturer: "Bosch",
|
||||
Model: "FLEXIDOME",
|
||||
FirmwareVersion: "8.71.0066",
|
||||
},
|
||||
TotalExchanges: 100,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
var parsed CaptureMetadata
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("Failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if parsed.Version != CaptureVersion {
|
||||
t.Errorf("Version = %v, want %v", parsed.Version, CaptureVersion)
|
||||
}
|
||||
|
||||
if parsed.CameraInfo.Manufacturer != "Bosch" {
|
||||
t.Errorf("Manufacturer = %v, want %v", parsed.CameraInfo.Manufacturer, "Bosch")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// Package onviftesting provides testing utilities for ONVIF client testing.
|
||||
package onviftesting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GoldenManifest describes a camera's golden file set.
|
||||
type GoldenManifest struct {
|
||||
Version string `json:"version"`
|
||||
Camera CameraInfo `json:"camera"`
|
||||
CaptureDate string `json:"capture_date"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
OperationCount map[string]int `json:"operation_count"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// GoldenFile represents a single operation's expected result.
|
||||
type GoldenFile struct {
|
||||
Operation string `json:"operation"`
|
||||
Service string `json:"service"`
|
||||
Parameters map[string]string `json:"parameters,omitempty"`
|
||||
Request string `json:"request"`
|
||||
Response string `json:"response"`
|
||||
ExpectedFields map[string]interface{} `json:"expected_fields,omitempty"`
|
||||
VariableFields []string `json:"variable_fields,omitempty"`
|
||||
}
|
||||
|
||||
// GoldenFileSet holds all golden files for a camera.
|
||||
type GoldenFileSet struct {
|
||||
Manifest *GoldenManifest
|
||||
Files map[string]*GoldenFile // key is operation + params
|
||||
BasePath string
|
||||
}
|
||||
|
||||
// LoadGoldenManifest loads a manifest.json from a golden directory.
|
||||
func LoadGoldenManifest(goldenDir string) (*GoldenManifest, error) {
|
||||
manifestPath := filepath.Join(goldenDir, "manifest.json")
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read manifest: %w", err)
|
||||
}
|
||||
|
||||
var manifest GoldenManifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal manifest: %w", err)
|
||||
}
|
||||
|
||||
return &manifest, nil
|
||||
}
|
||||
|
||||
// LoadGoldenFiles loads all golden files from a camera directory.
|
||||
func LoadGoldenFiles(goldenDir string) (*GoldenFileSet, error) {
|
||||
set := &GoldenFileSet{
|
||||
Files: make(map[string]*GoldenFile),
|
||||
BasePath: goldenDir,
|
||||
}
|
||||
|
||||
// Load manifest if it exists
|
||||
manifest, err := LoadGoldenManifest(goldenDir)
|
||||
if err == nil {
|
||||
set.Manifest = manifest
|
||||
}
|
||||
|
||||
// Walk through all JSON files in the directory
|
||||
err = filepath.Walk(goldenDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip directories and non-JSON files
|
||||
if info.IsDir() || filepath.Ext(path) != ".json" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip manifest.json
|
||||
if info.Name() == "manifest.json" {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read %s: %w", path, err)
|
||||
}
|
||||
|
||||
var golden GoldenFile
|
||||
if err := json.Unmarshal(data, &golden); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Build key from operation and parameters
|
||||
key := buildGoldenKey(&golden)
|
||||
set.Files[key] = &golden
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return set, nil
|
||||
}
|
||||
|
||||
// buildGoldenKey creates a unique key for a golden file.
|
||||
func buildGoldenKey(g *GoldenFile) string {
|
||||
key := g.Operation
|
||||
if g.Parameters != nil {
|
||||
// Sort parameters for consistent keys
|
||||
for k, v := range g.Parameters {
|
||||
key += "_" + k + "_" + v
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// GetGoldenFile retrieves a golden file by operation name and parameters.
|
||||
func (s *GoldenFileSet) GetGoldenFile(operation string, params map[string]string) *GoldenFile {
|
||||
// Try exact match first
|
||||
golden := &GoldenFile{Operation: operation, Parameters: params}
|
||||
key := buildGoldenKey(golden)
|
||||
if g, ok := s.Files[key]; ok {
|
||||
return g
|
||||
}
|
||||
|
||||
// Fall back to operation-only match
|
||||
for _, g := range s.Files {
|
||||
if g.Operation == operation {
|
||||
return g
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOperations returns all unique operations in the golden file set.
|
||||
func (s *GoldenFileSet) GetOperations() []string {
|
||||
seen := make(map[string]bool)
|
||||
var ops []string
|
||||
|
||||
for _, g := range s.Files {
|
||||
if !seen[g.Operation] {
|
||||
seen[g.Operation] = true
|
||||
ops = append(ops, g.Operation)
|
||||
}
|
||||
}
|
||||
|
||||
return ops
|
||||
}
|
||||
|
||||
// ValidateResponse validates a response against expected fields in a golden file.
|
||||
func ValidateResponse(response interface{}, golden *GoldenFile) []string {
|
||||
if golden.ExpectedFields == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var errors []string
|
||||
|
||||
// Convert response to map for comparison
|
||||
responseData, err := toMap(response)
|
||||
if err != nil {
|
||||
return []string{fmt.Sprintf("failed to convert response: %v", err)}
|
||||
}
|
||||
|
||||
// Check each expected field
|
||||
for field, expected := range golden.ExpectedFields {
|
||||
actual, ok := responseData[field]
|
||||
if !ok {
|
||||
errors = append(errors, fmt.Sprintf("missing field: %s", field))
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip variable fields (like timestamps)
|
||||
if isVariableField(field, golden.VariableFields) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Compare values
|
||||
if !valuesEqual(expected, actual) {
|
||||
errors = append(errors, fmt.Sprintf("field %s: expected %v, got %v", field, expected, actual))
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// toMap converts a struct to a map for field comparison.
|
||||
func toMap(v interface{}) (map[string]interface{}, error) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// isVariableField checks if a field should be skipped during validation.
|
||||
func isVariableField(field string, variableFields []string) bool {
|
||||
for _, v := range variableFields {
|
||||
if v == field {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// valuesEqual compares two values for equality.
|
||||
func valuesEqual(expected, actual interface{}) bool {
|
||||
// Handle nil comparison
|
||||
if expected == nil && actual == nil {
|
||||
return true
|
||||
}
|
||||
if expected == nil || actual == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Convert to JSON for deep comparison
|
||||
e, err1 := json.Marshal(expected)
|
||||
a, err2 := json.Marshal(actual)
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return string(e) == string(a)
|
||||
}
|
||||
|
||||
// SaveGoldenFile saves a golden file to disk.
|
||||
func SaveGoldenFile(golden *GoldenFile, outputPath string) error {
|
||||
data, err := json.MarshalIndent(golden, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal golden file: %w", err)
|
||||
}
|
||||
|
||||
// Create directory if needed
|
||||
dir := filepath.Dir(outputPath)
|
||||
if err := os.MkdirAll(dir, 0750); err != nil { //nolint:mnd
|
||||
return fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(outputPath, data, 0600); err != nil { //nolint:mnd
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveGoldenManifest saves a manifest file to disk.
|
||||
func SaveGoldenManifest(manifest *GoldenManifest, outputPath string) error {
|
||||
data, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal manifest: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(outputPath, data, 0600); err != nil { //nolint:mnd
|
||||
return fmt.Errorf("failed to write manifest: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateGoldenFileName generates a filename for a golden file.
|
||||
func GenerateGoldenFileName(operation string, params map[string]string) string {
|
||||
name := operation
|
||||
for k, v := range params {
|
||||
// Sanitize parameter value for filename
|
||||
v = strings.ReplaceAll(v, "/", "_")
|
||||
v = strings.ReplaceAll(v, "\\", "_")
|
||||
name += "_" + k + "_" + v
|
||||
}
|
||||
return name + ".json"
|
||||
}
|
||||
|
||||
// CreateGoldenFromCapture creates a golden file from a captured exchange.
|
||||
func CreateGoldenFromCapture(exchange *CapturedExchangeV2) *GoldenFile {
|
||||
params := make(map[string]string)
|
||||
if exchange.Parameters != nil {
|
||||
for k, v := range exchange.Parameters {
|
||||
if s, ok := v.(string); ok {
|
||||
params[k] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &GoldenFile{
|
||||
Operation: exchange.OperationName,
|
||||
Service: string(exchange.ServiceType),
|
||||
Parameters: params,
|
||||
Request: exchange.RequestBody,
|
||||
Response: exchange.ResponseBody,
|
||||
}
|
||||
}
|
||||
|
||||
// GoldenTestRunner helps run tests against golden files.
|
||||
type GoldenTestRunner struct {
|
||||
GoldenSet *GoldenFileSet
|
||||
}
|
||||
|
||||
// NewGoldenTestRunner creates a new golden test runner.
|
||||
func NewGoldenTestRunner(goldenDir string) (*GoldenTestRunner, error) {
|
||||
set, err := LoadGoldenFiles(goldenDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &GoldenTestRunner{GoldenSet: set}, nil
|
||||
}
|
||||
|
||||
// ValidateOperation validates a response against the golden file for an operation.
|
||||
func (r *GoldenTestRunner) ValidateOperation(operation string, params map[string]string, response interface{}) []string {
|
||||
golden := r.GoldenSet.GetGoldenFile(operation, params)
|
||||
if golden == nil {
|
||||
return []string{fmt.Sprintf("no golden file found for operation: %s", operation)}
|
||||
}
|
||||
|
||||
return ValidateResponse(response, golden)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -217,3 +218,399 @@ func extractOperationFromSOAP(soapBody string) string {
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Enhanced Mock Server with Parameter-Aware Matching (V2)
|
||||
// =============================================================================
|
||||
|
||||
// MockSOAPServerV2 supports parameter-aware request matching.
|
||||
// It maintains backward compatibility with V1 captures by falling back to
|
||||
// operation-name-only matching when parameters don't match.
|
||||
type MockSOAPServerV2 struct {
|
||||
Server *httptest.Server
|
||||
Capture *CameraCaptureV2
|
||||
exchangeMap map[string][]*CapturedExchangeV2 // operationName -> exchanges
|
||||
metadata *CaptureMetadata
|
||||
}
|
||||
|
||||
// NewMockSOAPServerV2 creates an enhanced mock server from a capture archive.
|
||||
// It supports both V1 and V2 capture formats.
|
||||
func NewMockSOAPServerV2(archivePath string) (*MockSOAPServerV2, error) {
|
||||
capture, metadata, err := LoadCaptureFromArchiveV2(archivePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mock := &MockSOAPServerV2{
|
||||
Capture: capture,
|
||||
metadata: metadata,
|
||||
exchangeMap: make(map[string][]*CapturedExchangeV2),
|
||||
}
|
||||
|
||||
// Build exchange map for quick lookup
|
||||
for i := range capture.Exchanges {
|
||||
ex := &capture.Exchanges[i]
|
||||
opName := ex.OperationName
|
||||
if opName == "" {
|
||||
// For V1 captures, extract from request body
|
||||
opName = extractOperationFromSOAP(ex.RequestBody)
|
||||
ex.OperationName = opName
|
||||
}
|
||||
mock.exchangeMap[opName] = append(mock.exchangeMap[opName], ex)
|
||||
}
|
||||
|
||||
mock.Server = httptest.NewServer(http.HandlerFunc(mock.handleRequest))
|
||||
return mock, nil
|
||||
}
|
||||
|
||||
// LoadCaptureFromArchiveV2 loads captures from archive, supporting both V1 and V2 formats.
|
||||
func LoadCaptureFromArchiveV2(archivePath string) (*CameraCaptureV2, *CaptureMetadata, error) {
|
||||
file, err := os.Open(archivePath) //nolint:gosec // File path is from test data, safe
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to open archive: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
}()
|
||||
|
||||
gzr, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create gzip reader: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = gzr.Close()
|
||||
}()
|
||||
|
||||
tr := tar.NewReader(gzr)
|
||||
|
||||
capture := &CameraCaptureV2{
|
||||
Exchanges: make([]CapturedExchangeV2, 0),
|
||||
}
|
||||
var metadata *CaptureMetadata
|
||||
|
||||
// Read all files from the archive
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to read tar header: %w", err)
|
||||
}
|
||||
|
||||
// Only process JSON files
|
||||
if !strings.HasSuffix(header.Name, ".json") {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to read file %s: %w", header.Name, err)
|
||||
}
|
||||
|
||||
// Check for metadata.json (V2 archives)
|
||||
if header.Name == "metadata.json" || strings.HasSuffix(header.Name, "/metadata.json") {
|
||||
var meta CaptureMetadata
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to unmarshal metadata: %w", err)
|
||||
}
|
||||
metadata = &meta
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip files that look like request/response XML stored as JSON
|
||||
if strings.Contains(header.Name, "_request") || strings.Contains(header.Name, "_response") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Detect version and unmarshal accordingly
|
||||
version := DetectCaptureVersion(data)
|
||||
if version >= "2.0" {
|
||||
var exchange CapturedExchangeV2
|
||||
if err := json.Unmarshal(data, &exchange); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to unmarshal V2 %s: %w", header.Name, err)
|
||||
}
|
||||
capture.Exchanges = append(capture.Exchanges, exchange)
|
||||
} else {
|
||||
// V1 format - convert to V2
|
||||
var v1Exchange CapturedExchange
|
||||
if err := json.Unmarshal(data, &v1Exchange); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to unmarshal V1 %s: %w", header.Name, err)
|
||||
}
|
||||
v2Exchange := ConvertV1ToV2(&v1Exchange)
|
||||
// Extract parameters from V1 request body
|
||||
v2Exchange.Parameters = ExtractParameters(v2Exchange.OperationName, v2Exchange.RequestBody)
|
||||
v2Exchange.ServiceType = DetermineServiceType(v2Exchange.RequestBody)
|
||||
capture.Exchanges = append(capture.Exchanges, *v2Exchange)
|
||||
}
|
||||
}
|
||||
|
||||
capture.Metadata = metadata
|
||||
return capture, metadata, nil
|
||||
}
|
||||
|
||||
// handleRequest matches incoming requests to captured responses with parameter awareness.
|
||||
func (m *MockSOAPServerV2) handleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
reqBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
operationName := extractOperationFromSOAP(string(reqBody))
|
||||
if operationName == "" {
|
||||
http.Error(w, "Could not extract operation name from request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get all exchanges for this operation
|
||||
exchanges, ok := m.exchangeMap[operationName]
|
||||
if !ok || len(exchanges) == 0 {
|
||||
http.Error(w, fmt.Sprintf("No capture found for operation: %s", operationName), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract parameters from request for matching
|
||||
requestParams := ExtractParameters(operationName, string(reqBody))
|
||||
requestKey := BuildMatchKey(operationName, requestParams)
|
||||
|
||||
// Find best matching exchange
|
||||
var bestMatch *CapturedExchangeV2
|
||||
bestScore := -1
|
||||
|
||||
for _, ex := range exchanges {
|
||||
exchangeKey := BuildMatchKeyFromExchange(ex)
|
||||
score := requestKey.MatchScore(exchangeKey)
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestMatch = ex
|
||||
}
|
||||
}
|
||||
|
||||
if bestMatch == nil {
|
||||
// Fall back to first exchange for this operation (V1 behavior)
|
||||
bestMatch = exchanges[0]
|
||||
}
|
||||
|
||||
// Return the captured response
|
||||
w.Header().Set("Content-Type", "application/soap+xml; charset=utf-8")
|
||||
w.WriteHeader(bestMatch.StatusCode)
|
||||
//nolint:errcheck // Write error is not critical after WriteHeader
|
||||
_, _ = w.Write([]byte(bestMatch.ResponseBody))
|
||||
}
|
||||
|
||||
// Close shuts down the V2 mock server.
|
||||
func (m *MockSOAPServerV2) Close() {
|
||||
m.Server.Close()
|
||||
}
|
||||
|
||||
// URL returns the V2 mock server's URL.
|
||||
func (m *MockSOAPServerV2) URL() string {
|
||||
return m.Server.URL
|
||||
}
|
||||
|
||||
// Metadata returns the capture metadata if available (V2 archives only).
|
||||
func (m *MockSOAPServerV2) Metadata() *CaptureMetadata {
|
||||
return m.metadata
|
||||
}
|
||||
|
||||
// GetExchangeCount returns the total number of captured exchanges.
|
||||
func (m *MockSOAPServerV2) GetExchangeCount() int {
|
||||
return len(m.Capture.Exchanges)
|
||||
}
|
||||
|
||||
// GetOperations returns all unique operation names in the capture.
|
||||
func (m *MockSOAPServerV2) GetOperations() []string {
|
||||
ops := make([]string, 0, len(m.exchangeMap))
|
||||
for op := range m.exchangeMap {
|
||||
ops = append(ops, op)
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Parameter Extraction
|
||||
// =============================================================================
|
||||
|
||||
// tokenParams are common ONVIF token parameters to extract.
|
||||
var tokenParams = []string{
|
||||
// Core tokens
|
||||
"ProfileToken",
|
||||
"ConfigurationToken",
|
||||
"VideoSourceToken",
|
||||
"AudioSourceToken",
|
||||
"PresetToken",
|
||||
"Token",
|
||||
// Configuration tokens
|
||||
"VideoSourceConfigurationToken",
|
||||
"AudioSourceConfigurationToken",
|
||||
"VideoEncoderConfigurationToken",
|
||||
"AudioEncoderConfigurationToken",
|
||||
"MetadataConfigurationToken",
|
||||
"PTZConfigurationToken",
|
||||
// Event/subscription tokens
|
||||
"SubscriptionReference",
|
||||
// Extended tokens (Task 5 additions)
|
||||
"OSDToken",
|
||||
"NodeToken",
|
||||
"RelayOutputToken",
|
||||
"VideoOutputToken",
|
||||
"DigitalInputToken",
|
||||
"SerialPortToken",
|
||||
"StorageConfigurationToken",
|
||||
"CertificateID",
|
||||
"RecordingToken",
|
||||
"RecordingJobToken",
|
||||
"AnalyticsConfigurationToken",
|
||||
"RuleToken",
|
||||
"ScheduleToken",
|
||||
"SpecialDayGroupToken",
|
||||
}
|
||||
|
||||
// paramRegexes are compiled regexes for extracting parameters.
|
||||
var paramRegexes = make(map[string]*regexp.Regexp)
|
||||
|
||||
func init() {
|
||||
// Pre-compile regexes for token extraction
|
||||
for _, param := range tokenParams {
|
||||
// Match both <ProfileToken>value</ProfileToken> and <trt:ProfileToken>value</trt:ProfileToken>
|
||||
pattern := fmt.Sprintf(`<%s[^>]*>([^<]+)</%s>|<[a-z]+:%s[^>]*>([^<]+)</[a-z]+:%s>`,
|
||||
param, param, param, param)
|
||||
paramRegexes[param] = regexp.MustCompile(pattern)
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractParameters extracts key parameters from a SOAP request body.
|
||||
func ExtractParameters(operationName, soapBody string) map[string]interface{} {
|
||||
params := make(map[string]interface{})
|
||||
|
||||
for _, paramName := range tokenParams {
|
||||
re := paramRegexes[paramName]
|
||||
if re == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
matches := re.FindStringSubmatch(soapBody)
|
||||
if len(matches) > 1 {
|
||||
// Get the first non-empty capture group
|
||||
for i := 1; i < len(matches); i++ {
|
||||
if matches[i] != "" {
|
||||
params[paramName] = strings.TrimSpace(matches[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// ExtractXMLElement extracts a simple XML element value from a string.
|
||||
func ExtractXMLElement(xml, element string) string {
|
||||
// Try without namespace prefix first
|
||||
start := fmt.Sprintf("<%s>", element)
|
||||
end := fmt.Sprintf("</%s>", element)
|
||||
|
||||
startIdx := strings.Index(xml, start)
|
||||
if startIdx != -1 {
|
||||
startIdx += len(start)
|
||||
endIdx := strings.Index(xml[startIdx:], end)
|
||||
if endIdx != -1 {
|
||||
return strings.TrimSpace(xml[startIdx : startIdx+endIdx])
|
||||
}
|
||||
}
|
||||
|
||||
// Try with namespace prefix pattern :<element>
|
||||
pattern := fmt.Sprintf(":%s>", element)
|
||||
startIdx = strings.Index(xml, pattern)
|
||||
if startIdx != -1 {
|
||||
startIdx += len(pattern)
|
||||
// Find closing tag with any namespace prefix
|
||||
endPattern := fmt.Sprintf("</%s>", element)
|
||||
endIdx := strings.Index(xml[startIdx:], endPattern)
|
||||
if endIdx == -1 {
|
||||
// Try with namespace prefix in closing tag
|
||||
for i := startIdx; i < len(xml); i++ {
|
||||
if xml[i] == '<' && i+1 < len(xml) && xml[i+1] == '/' {
|
||||
// Found potential closing tag
|
||||
closeEnd := strings.Index(xml[i:], ">")
|
||||
if closeEnd != -1 {
|
||||
closeTag := xml[i : i+closeEnd+1]
|
||||
if strings.Contains(closeTag, element) {
|
||||
return strings.TrimSpace(xml[startIdx:i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return strings.TrimSpace(xml[startIdx : startIdx+endIdx])
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SOAP Fault Support
|
||||
// =============================================================================
|
||||
|
||||
// SOAPFault represents a SOAP fault for error responses.
|
||||
type SOAPFault struct {
|
||||
Code string `json:"code"`
|
||||
Reason string `json:"reason"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// Common ONVIF SOAP faults.
|
||||
var (
|
||||
FaultActionNotSupported = SOAPFault{
|
||||
Code: "env:Sender/ter:ActionNotSupported",
|
||||
Reason: "The requested action is not supported by the service",
|
||||
}
|
||||
FaultInvalidToken = SOAPFault{
|
||||
Code: "env:Sender/ter:InvalidArgVal/ter:NoProfile",
|
||||
Reason: "The requested profile token does not exist",
|
||||
}
|
||||
FaultNotAuthorized = SOAPFault{
|
||||
Code: "env:Sender/ter:NotAuthorized",
|
||||
Reason: "The sender is not authorized to perform the operation",
|
||||
}
|
||||
FaultInvalidArgument = SOAPFault{
|
||||
Code: "env:Sender/ter:InvalidArgVal",
|
||||
Reason: "One or more arguments are invalid",
|
||||
}
|
||||
FaultOperationFailed = SOAPFault{
|
||||
Code: "env:Receiver/ter:Action",
|
||||
Reason: "The operation failed",
|
||||
}
|
||||
)
|
||||
|
||||
// GenerateFaultResponse creates a SOAP fault response XML.
|
||||
func GenerateFaultResponse(fault SOAPFault) string {
|
||||
detail := ""
|
||||
if fault.Detail != "" {
|
||||
detail = fmt.Sprintf("<soap:Detail>%s</soap:Detail>", fault.Detail)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope" xmlns:ter="http://www.onvif.org/ver10/error">
|
||||
<soap:Body>
|
||||
<soap:Fault>
|
||||
<soap:Code>
|
||||
<soap:Value>%s</soap:Value>
|
||||
</soap:Code>
|
||||
<soap:Reason>
|
||||
<soap:Text xml:lang="en">%s</soap:Text>
|
||||
</soap:Reason>
|
||||
%s
|
||||
</soap:Fault>
|
||||
</soap:Body>
|
||||
</soap:Envelope>`, fault.Code, fault.Reason, detail)
|
||||
}
|
||||
|
||||
// IsFaultResponse checks if a response body contains a SOAP fault.
|
||||
func IsFaultResponse(responseBody string) bool {
|
||||
return strings.Contains(responseBody, "<soap:Fault>") ||
|
||||
strings.Contains(responseBody, "<Fault>") ||
|
||||
strings.Contains(responseBody, ":Fault>")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
// Package onviftesting provides testing utilities for ONVIF client testing.
|
||||
package onviftesting
|
||||
|
||||
// OperationSpec defines how to capture an ONVIF operation.
|
||||
type OperationSpec struct {
|
||||
// Name is the ONVIF operation name (e.g., "GetDeviceInformation")
|
||||
Name string
|
||||
|
||||
// Service is the ONVIF service type
|
||||
Service ServiceType
|
||||
|
||||
// RequiresInit indicates if Initialize() must be called first
|
||||
RequiresInit bool
|
||||
|
||||
// RequiresToken specifies which token parameter is needed (e.g., "ProfileToken")
|
||||
RequiresToken string
|
||||
|
||||
// DependsOn specifies which operation provides the required token
|
||||
DependsOn string
|
||||
|
||||
// Category groups related operations (e.g., "core", "network", "security")
|
||||
Category string
|
||||
|
||||
// IsWrite indicates if this operation modifies camera state
|
||||
IsWrite bool
|
||||
|
||||
// Description provides a brief description of the operation
|
||||
Description string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Device Service Operations (97 total, ~35 READ operations)
|
||||
// =============================================================================
|
||||
|
||||
// DeviceReadOperations contains all read-only Device service operations.
|
||||
var DeviceReadOperations = []OperationSpec{
|
||||
// Core operations
|
||||
{Name: "GetDeviceInformation", Service: ServiceDevice, Category: "core",
|
||||
Description: "Get manufacturer, model, firmware version"},
|
||||
{Name: "GetCapabilities", Service: ServiceDevice, Category: "core",
|
||||
Description: "Get service capabilities and endpoints"},
|
||||
{Name: "GetServices", Service: ServiceDevice, Category: "core",
|
||||
Description: "Get list of available services"},
|
||||
{Name: "GetServiceCapabilities", Service: ServiceDevice, Category: "core",
|
||||
Description: "Get device service capabilities"},
|
||||
|
||||
// System operations
|
||||
{Name: "GetSystemDateAndTime", Service: ServiceDevice, Category: "system",
|
||||
Description: "Get device date and time settings"},
|
||||
{Name: "GetSystemLog", Service: ServiceDevice, Category: "system",
|
||||
Description: "Get system log"},
|
||||
{Name: "GetSystemUris", Service: ServiceDevice, Category: "system",
|
||||
Description: "Get system URIs (support, firmware, logs)"},
|
||||
{Name: "GetSystemSupportInformation", Service: ServiceDevice, Category: "system",
|
||||
Description: "Get system support information"},
|
||||
{Name: "GetEndpointReference", Service: ServiceDevice, Category: "system",
|
||||
Description: "Get unique endpoint reference address"},
|
||||
|
||||
// Network operations
|
||||
{Name: "GetHostname", Service: ServiceDevice, Category: "network",
|
||||
Description: "Get device hostname"},
|
||||
{Name: "GetDNS", Service: ServiceDevice, Category: "network",
|
||||
Description: "Get DNS configuration"},
|
||||
{Name: "GetNTP", Service: ServiceDevice, Category: "network",
|
||||
Description: "Get NTP configuration"},
|
||||
{Name: "GetNetworkInterfaces", Service: ServiceDevice, Category: "network",
|
||||
Description: "Get network interface configuration"},
|
||||
{Name: "GetNetworkProtocols", Service: ServiceDevice, Category: "network",
|
||||
Description: "Get enabled network protocols"},
|
||||
{Name: "GetNetworkDefaultGateway", Service: ServiceDevice, Category: "network",
|
||||
Description: "Get default gateway configuration"},
|
||||
|
||||
// Discovery operations
|
||||
{Name: "GetDiscoveryMode", Service: ServiceDevice, Category: "discovery",
|
||||
Description: "Get WS-Discovery mode"},
|
||||
{Name: "GetRemoteDiscoveryMode", Service: ServiceDevice, Category: "discovery",
|
||||
Description: "Get remote discovery mode"},
|
||||
|
||||
// Scope operations
|
||||
{Name: "GetScopes", Service: ServiceDevice, Category: "scopes",
|
||||
Description: "Get device scopes for discovery"},
|
||||
|
||||
// User operations
|
||||
{Name: "GetUsers", Service: ServiceDevice, Category: "users",
|
||||
Description: "Get list of device users"},
|
||||
|
||||
// Security operations
|
||||
{Name: "GetRemoteUser", Service: ServiceDevice, Category: "security",
|
||||
Description: "Get remote user configuration"},
|
||||
{Name: "GetIPAddressFilter", Service: ServiceDevice, Category: "security",
|
||||
Description: "Get IP address filter rules"},
|
||||
{Name: "GetZeroConfiguration", Service: ServiceDevice, Category: "security",
|
||||
Description: "Get zero configuration (link-local) settings"},
|
||||
{Name: "GetDynamicDNS", Service: ServiceDevice, Category: "security",
|
||||
Description: "Get dynamic DNS configuration"},
|
||||
{Name: "GetAccessPolicy", Service: ServiceDevice, Category: "security",
|
||||
Description: "Get access policy configuration"},
|
||||
{Name: "GetPasswordComplexityConfiguration", Service: ServiceDevice, Category: "security",
|
||||
Description: "Get password complexity requirements"},
|
||||
{Name: "GetPasswordHistoryConfiguration", Service: ServiceDevice, Category: "security",
|
||||
Description: "Get password history configuration"},
|
||||
{Name: "GetAuthFailureWarningConfiguration", Service: ServiceDevice, Category: "security",
|
||||
Description: "Get authentication failure warning settings"},
|
||||
|
||||
// Certificate operations
|
||||
{Name: "GetCertificates", Service: ServiceDevice, Category: "certificates",
|
||||
Description: "Get device certificates"},
|
||||
{Name: "GetCACertificates", Service: ServiceDevice, Category: "certificates",
|
||||
Description: "Get CA certificates"},
|
||||
{Name: "GetCertificatesStatus", Service: ServiceDevice, Category: "certificates",
|
||||
Description: "Get certificate status"},
|
||||
{Name: "GetClientCertificateMode", Service: ServiceDevice, Category: "certificates",
|
||||
Description: "Get client certificate mode"},
|
||||
|
||||
// Storage operations
|
||||
{Name: "GetStorageConfigurations", Service: ServiceDevice, Category: "storage",
|
||||
Description: "Get storage configurations"},
|
||||
|
||||
// Relay operations
|
||||
{Name: "GetRelayOutputs", Service: ServiceDevice, Category: "relay",
|
||||
Description: "Get relay output states"},
|
||||
|
||||
// Additional operations
|
||||
{Name: "GetGeoLocation", Service: ServiceDevice, Category: "additional",
|
||||
Description: "Get geographic location"},
|
||||
{Name: "GetDPAddresses", Service: ServiceDevice, Category: "additional",
|
||||
Description: "Get DP (discovery proxy) addresses"},
|
||||
{Name: "GetWsdlURL", Service: ServiceDevice, Category: "additional",
|
||||
Description: "Get WSDL URL"},
|
||||
|
||||
// WiFi operations (802.11)
|
||||
{Name: "GetDot11Capabilities", Service: ServiceDevice, Category: "wifi",
|
||||
Description: "Get 802.11 capabilities"},
|
||||
{Name: "GetDot11Status", Service: ServiceDevice, Category: "wifi",
|
||||
Description: "Get 802.11 connection status"},
|
||||
{Name: "GetDot1XConfigurations", Service: ServiceDevice, Category: "wifi",
|
||||
Description: "Get 802.1X configurations"},
|
||||
{Name: "ScanAvailableDot11Networks", Service: ServiceDevice, Category: "wifi",
|
||||
Description: "Scan for available WiFi networks"},
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Media Service Operations (82 total, ~45 READ operations)
|
||||
// =============================================================================
|
||||
|
||||
// MediaReadOperations contains all read-only Media service operations.
|
||||
var MediaReadOperations = []OperationSpec{
|
||||
// Service capabilities
|
||||
{Name: "GetMediaServiceCapabilities", Service: ServiceMedia, RequiresInit: true, Category: "core",
|
||||
Description: "Get media service capabilities"},
|
||||
|
||||
// Profile operations
|
||||
{Name: "GetProfiles", Service: ServiceMedia, RequiresInit: true, Category: "profiles",
|
||||
Description: "Get all media profiles"},
|
||||
{Name: "GetProfile", Service: ServiceMedia, RequiresInit: true, Category: "profiles",
|
||||
RequiresToken: "ProfileToken", DependsOn: "GetProfiles",
|
||||
Description: "Get specific profile by token"},
|
||||
|
||||
// Video source operations
|
||||
{Name: "GetVideoSources", Service: ServiceMedia, RequiresInit: true, Category: "video",
|
||||
Description: "Get video sources"},
|
||||
{Name: "GetVideoSourceConfigurations", Service: ServiceMedia, RequiresInit: true, Category: "video",
|
||||
Description: "Get all video source configurations"},
|
||||
{Name: "GetVideoSourceConfiguration", Service: ServiceMedia, RequiresInit: true, Category: "video",
|
||||
RequiresToken: "ConfigurationToken", DependsOn: "GetVideoSourceConfigurations",
|
||||
Description: "Get specific video source configuration"},
|
||||
{Name: "GetVideoSourceConfigurationOptions", Service: ServiceMedia, RequiresInit: true, Category: "video",
|
||||
RequiresToken: "ConfigurationToken", DependsOn: "GetVideoSourceConfigurations",
|
||||
Description: "Get video source configuration options"},
|
||||
{Name: "GetVideoSourceModes", Service: ServiceMedia, RequiresInit: true, Category: "video",
|
||||
RequiresToken: "VideoSourceToken", DependsOn: "GetVideoSources",
|
||||
Description: "Get video source modes"},
|
||||
{Name: "GetCompatibleVideoSourceConfigurations", Service: ServiceMedia, RequiresInit: true, Category: "video",
|
||||
RequiresToken: "ProfileToken", DependsOn: "GetProfiles",
|
||||
Description: "Get compatible video source configurations for profile"},
|
||||
|
||||
// Video encoder operations
|
||||
{Name: "GetVideoEncoderConfigurations", Service: ServiceMedia, RequiresInit: true, Category: "encoder",
|
||||
Description: "Get all video encoder configurations"},
|
||||
{Name: "GetVideoEncoderConfiguration", Service: ServiceMedia, RequiresInit: true, Category: "encoder",
|
||||
RequiresToken: "ConfigurationToken", DependsOn: "GetVideoEncoderConfigurations",
|
||||
Description: "Get specific video encoder configuration"},
|
||||
{Name: "GetVideoEncoderConfigurationOptions", Service: ServiceMedia, RequiresInit: true, Category: "encoder",
|
||||
RequiresToken: "ConfigurationToken", DependsOn: "GetVideoEncoderConfigurations",
|
||||
Description: "Get video encoder configuration options"},
|
||||
{Name: "GetCompatibleVideoEncoderConfigurations", Service: ServiceMedia, RequiresInit: true, Category: "encoder",
|
||||
RequiresToken: "ProfileToken", DependsOn: "GetProfiles",
|
||||
Description: "Get compatible video encoder configurations for profile"},
|
||||
{Name: "GetGuaranteedNumberOfVideoEncoderInstances", Service: ServiceMedia, RequiresInit: true, Category: "encoder",
|
||||
RequiresToken: "ConfigurationToken", DependsOn: "GetVideoEncoderConfigurations",
|
||||
Description: "Get guaranteed number of video encoder instances"},
|
||||
|
||||
// Audio source operations
|
||||
{Name: "GetAudioSources", Service: ServiceMedia, RequiresInit: true, Category: "audio",
|
||||
Description: "Get audio sources"},
|
||||
{Name: "GetAudioSourceConfigurations", Service: ServiceMedia, RequiresInit: true, Category: "audio",
|
||||
Description: "Get all audio source configurations"},
|
||||
{Name: "GetAudioSourceConfiguration", Service: ServiceMedia, RequiresInit: true, Category: "audio",
|
||||
RequiresToken: "ConfigurationToken", DependsOn: "GetAudioSourceConfigurations",
|
||||
Description: "Get specific audio source configuration"},
|
||||
{Name: "GetAudioSourceConfigurationOptions", Service: ServiceMedia, RequiresInit: true, Category: "audio",
|
||||
RequiresToken: "ConfigurationToken", DependsOn: "GetAudioSourceConfigurations",
|
||||
Description: "Get audio source configuration options"},
|
||||
{Name: "GetCompatibleAudioSourceConfigurations", Service: ServiceMedia, RequiresInit: true, Category: "audio",
|
||||
RequiresToken: "ProfileToken", DependsOn: "GetProfiles",
|
||||
Description: "Get compatible audio source configurations for profile"},
|
||||
|
||||
// Audio encoder operations
|
||||
{Name: "GetAudioEncoderConfigurations", Service: ServiceMedia, RequiresInit: true, Category: "audio",
|
||||
Description: "Get all audio encoder configurations"},
|
||||
{Name: "GetAudioEncoderConfiguration", Service: ServiceMedia, RequiresInit: true, Category: "audio",
|
||||
RequiresToken: "ConfigurationToken", DependsOn: "GetAudioEncoderConfigurations",
|
||||
Description: "Get specific audio encoder configuration"},
|
||||
{Name: "GetAudioEncoderConfigurationOptions", Service: ServiceMedia, RequiresInit: true, Category: "audio",
|
||||
RequiresToken: "ConfigurationToken", DependsOn: "GetAudioEncoderConfigurations",
|
||||
Description: "Get audio encoder configuration options"},
|
||||
{Name: "GetCompatibleAudioEncoderConfigurations", Service: ServiceMedia, RequiresInit: true, Category: "audio",
|
||||
RequiresToken: "ProfileToken", DependsOn: "GetProfiles",
|
||||
Description: "Get compatible audio encoder configurations for profile"},
|
||||
|
||||
// Audio output operations
|
||||
{Name: "GetAudioOutputs", Service: ServiceMedia, RequiresInit: true, Category: "audio",
|
||||
Description: "Get audio outputs"},
|
||||
{Name: "GetAudioOutputConfigurations", Service: ServiceMedia, RequiresInit: true, Category: "audio",
|
||||
Description: "Get all audio output configurations"},
|
||||
{Name: "GetAudioOutputConfiguration", Service: ServiceMedia, RequiresInit: true, Category: "audio",
|
||||
RequiresToken: "ConfigurationToken", DependsOn: "GetAudioOutputConfigurations",
|
||||
Description: "Get specific audio output configuration"},
|
||||
{Name: "GetAudioOutputConfigurationOptions", Service: ServiceMedia, RequiresInit: true, Category: "audio",
|
||||
RequiresToken: "ConfigurationToken", DependsOn: "GetAudioOutputConfigurations",
|
||||
Description: "Get audio output configuration options"},
|
||||
{Name: "GetCompatibleAudioOutputConfigurations", Service: ServiceMedia, RequiresInit: true, Category: "audio",
|
||||
RequiresToken: "ProfileToken", DependsOn: "GetProfiles",
|
||||
Description: "Get compatible audio output configurations for profile"},
|
||||
|
||||
// Audio decoder operations
|
||||
{Name: "GetAudioDecoderConfigurations", Service: ServiceMedia, RequiresInit: true, Category: "audio",
|
||||
Description: "Get all audio decoder configurations"},
|
||||
{Name: "GetAudioDecoderConfiguration", Service: ServiceMedia, RequiresInit: true, Category: "audio",
|
||||
RequiresToken: "ConfigurationToken", DependsOn: "GetAudioDecoderConfigurations",
|
||||
Description: "Get specific audio decoder configuration"},
|
||||
{Name: "GetAudioDecoderConfigurationOptions", Service: ServiceMedia, RequiresInit: true, Category: "audio",
|
||||
RequiresToken: "ConfigurationToken", DependsOn: "GetAudioDecoderConfigurations",
|
||||
Description: "Get audio decoder configuration options"},
|
||||
{Name: "GetCompatibleAudioDecoderConfigurations", Service: ServiceMedia, RequiresInit: true, Category: "audio",
|
||||
RequiresToken: "ProfileToken", DependsOn: "GetProfiles",
|
||||
Description: "Get compatible audio decoder configurations for profile"},
|
||||
|
||||
// Metadata operations
|
||||
{Name: "GetMetadataConfigurations", Service: ServiceMedia, RequiresInit: true, Category: "metadata",
|
||||
Description: "Get all metadata configurations"},
|
||||
{Name: "GetMetadataConfiguration", Service: ServiceMedia, RequiresInit: true, Category: "metadata",
|
||||
RequiresToken: "ConfigurationToken", DependsOn: "GetMetadataConfigurations",
|
||||
Description: "Get specific metadata configuration"},
|
||||
{Name: "GetMetadataConfigurationOptions", Service: ServiceMedia, RequiresInit: true, Category: "metadata",
|
||||
RequiresToken: "ConfigurationToken", DependsOn: "GetMetadataConfigurations",
|
||||
Description: "Get metadata configuration options"},
|
||||
{Name: "GetCompatibleMetadataConfigurations", Service: ServiceMedia, RequiresInit: true, Category: "metadata",
|
||||
RequiresToken: "ProfileToken", DependsOn: "GetProfiles",
|
||||
Description: "Get compatible metadata configurations for profile"},
|
||||
|
||||
// Video analytics operations
|
||||
{Name: "GetVideoAnalyticsConfigurations", Service: ServiceMedia, RequiresInit: true, Category: "analytics",
|
||||
Description: "Get all video analytics configurations"},
|
||||
{Name: "GetVideoAnalyticsConfiguration", Service: ServiceMedia, RequiresInit: true, Category: "analytics",
|
||||
RequiresToken: "ConfigurationToken", DependsOn: "GetVideoAnalyticsConfigurations",
|
||||
Description: "Get specific video analytics configuration"},
|
||||
{Name: "GetCompatibleVideoAnalyticsConfigurations", Service: ServiceMedia, RequiresInit: true, Category: "analytics",
|
||||
RequiresToken: "ProfileToken", DependsOn: "GetProfiles",
|
||||
Description: "Get compatible video analytics configurations for profile"},
|
||||
|
||||
// Stream operations
|
||||
{Name: "GetStreamURI", Service: ServiceMedia, RequiresInit: true, Category: "streaming",
|
||||
RequiresToken: "ProfileToken", DependsOn: "GetProfiles",
|
||||
Description: "Get RTSP stream URI"},
|
||||
{Name: "GetSnapshotURI", Service: ServiceMedia, RequiresInit: true, Category: "streaming",
|
||||
RequiresToken: "ProfileToken", DependsOn: "GetProfiles",
|
||||
Description: "Get snapshot URI"},
|
||||
|
||||
// OSD operations
|
||||
{Name: "GetOSDs", Service: ServiceMedia, RequiresInit: true, Category: "osd",
|
||||
Description: "Get all OSD configurations"},
|
||||
{Name: "GetOSD", Service: ServiceMedia, RequiresInit: true, Category: "osd",
|
||||
RequiresToken: "ConfigurationToken", DependsOn: "GetOSDs",
|
||||
Description: "Get specific OSD configuration"},
|
||||
{Name: "GetOSDOptions", Service: ServiceMedia, RequiresInit: true, Category: "osd",
|
||||
RequiresToken: "ConfigurationToken", DependsOn: "GetOSDs",
|
||||
Description: "Get OSD configuration options"},
|
||||
|
||||
// PTZ configuration operations (on Media service)
|
||||
{Name: "GetCompatiblePTZConfigurations", Service: ServiceMedia, RequiresInit: true, Category: "ptz",
|
||||
RequiresToken: "ProfileToken", DependsOn: "GetProfiles",
|
||||
Description: "Get compatible PTZ configurations for profile"},
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PTZ Service Operations (13 total, ~5 READ operations)
|
||||
// =============================================================================
|
||||
|
||||
// PTZReadOperations contains all read-only PTZ service operations.
|
||||
var PTZReadOperations = []OperationSpec{
|
||||
{Name: "GetConfigurations", Service: ServicePTZ, RequiresInit: true, Category: "config",
|
||||
Description: "Get all PTZ configurations"},
|
||||
{Name: "GetConfiguration", Service: ServicePTZ, RequiresInit: true, Category: "config",
|
||||
RequiresToken: "PTZConfigurationToken", DependsOn: "GetConfigurations",
|
||||
Description: "Get specific PTZ configuration"},
|
||||
{Name: "GetStatus", Service: ServicePTZ, RequiresInit: true, Category: "status",
|
||||
RequiresToken: "ProfileToken", DependsOn: "GetProfiles",
|
||||
Description: "Get PTZ status (position, move status)"},
|
||||
{Name: "GetPresets", Service: ServicePTZ, RequiresInit: true, Category: "presets",
|
||||
RequiresToken: "ProfileToken", DependsOn: "GetProfiles",
|
||||
Description: "Get PTZ presets"},
|
||||
{Name: "GetNodes", Service: ServicePTZ, RequiresInit: true, Category: "nodes",
|
||||
Description: "Get PTZ nodes"},
|
||||
{Name: "GetNode", Service: ServicePTZ, RequiresInit: true, Category: "nodes",
|
||||
RequiresToken: "NodeToken", DependsOn: "GetNodes",
|
||||
Description: "Get specific PTZ node"},
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Imaging Service Operations (7 total, ~4 READ operations)
|
||||
// =============================================================================
|
||||
|
||||
// ImagingReadOperations contains all read-only Imaging service operations.
|
||||
var ImagingReadOperations = []OperationSpec{
|
||||
{Name: "GetImagingSettings", Service: ServiceImaging, RequiresInit: true, Category: "settings",
|
||||
RequiresToken: "VideoSourceToken", DependsOn: "GetVideoSources",
|
||||
Description: "Get imaging settings (brightness, contrast, etc.)"},
|
||||
{Name: "GetOptions", Service: ServiceImaging, RequiresInit: true, Category: "options",
|
||||
RequiresToken: "VideoSourceToken", DependsOn: "GetVideoSources",
|
||||
Description: "Get imaging options and ranges"},
|
||||
{Name: "GetMoveOptions", Service: ServiceImaging, RequiresInit: true, Category: "options",
|
||||
RequiresToken: "VideoSourceToken", DependsOn: "GetVideoSources",
|
||||
Description: "Get focus move options"},
|
||||
{Name: "GetImagingStatus", Service: ServiceImaging, RequiresInit: true, Category: "status",
|
||||
RequiresToken: "VideoSourceToken", DependsOn: "GetVideoSources",
|
||||
Description: "Get imaging status (focus status, etc.)"},
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Event Service Operations (12 total, ~3 READ operations)
|
||||
// =============================================================================
|
||||
|
||||
// EventReadOperations contains all read-only Event service operations.
|
||||
var EventReadOperations = []OperationSpec{
|
||||
{Name: "GetEventServiceCapabilities", Service: ServiceEvent, RequiresInit: true, Category: "core",
|
||||
Description: "Get event service capabilities"},
|
||||
{Name: "GetEventProperties", Service: ServiceEvent, RequiresInit: true, Category: "core",
|
||||
Description: "Get event topic properties"},
|
||||
{Name: "GetEventBrokers", Service: ServiceEvent, RequiresInit: true, Category: "brokers",
|
||||
Description: "Get event brokers"},
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DeviceIO Service Operations (14 total, ~11 READ operations)
|
||||
// =============================================================================
|
||||
|
||||
// DeviceIOReadOperations contains all read-only DeviceIO service operations.
|
||||
var DeviceIOReadOperations = []OperationSpec{
|
||||
{Name: "GetDeviceIOServiceCapabilities", Service: ServiceDeviceIO, RequiresInit: true, Category: "core",
|
||||
Description: "Get DeviceIO service capabilities"},
|
||||
{Name: "GetDigitalInputs", Service: ServiceDeviceIO, RequiresInit: true, Category: "inputs",
|
||||
Description: "Get digital inputs"},
|
||||
{Name: "GetDigitalInputConfigurationOptions", Service: ServiceDeviceIO, RequiresInit: true, Category: "inputs",
|
||||
Description: "Get digital input configuration options"},
|
||||
{Name: "GetVideoOutputs", Service: ServiceDeviceIO, RequiresInit: true, Category: "outputs",
|
||||
Description: "Get video outputs"},
|
||||
{Name: "GetVideoOutputConfiguration", Service: ServiceDeviceIO, RequiresInit: true, Category: "outputs",
|
||||
RequiresToken: "VideoOutputToken", DependsOn: "GetVideoOutputs",
|
||||
Description: "Get video output configuration"},
|
||||
{Name: "GetVideoOutputConfigurationOptions", Service: ServiceDeviceIO, RequiresInit: true, Category: "outputs",
|
||||
RequiresToken: "VideoOutputToken", DependsOn: "GetVideoOutputs",
|
||||
Description: "Get video output configuration options"},
|
||||
{Name: "GetSerialPorts", Service: ServiceDeviceIO, RequiresInit: true, Category: "serial",
|
||||
Description: "Get serial ports"},
|
||||
{Name: "GetSerialPortConfiguration", Service: ServiceDeviceIO, RequiresInit: true, Category: "serial",
|
||||
RequiresToken: "SerialPortToken", DependsOn: "GetSerialPorts",
|
||||
Description: "Get serial port configuration"},
|
||||
{Name: "GetSerialPortConfigurationOptions", Service: ServiceDeviceIO, RequiresInit: true, Category: "serial",
|
||||
RequiresToken: "SerialPortToken", DependsOn: "GetSerialPorts",
|
||||
Description: "Get serial port configuration options"},
|
||||
{Name: "GetRelayOutputOptions", Service: ServiceDeviceIO, RequiresInit: true, Category: "relay",
|
||||
RequiresToken: "RelayOutputToken",
|
||||
Description: "Get relay output options"},
|
||||
{Name: "GetAudioOutputs", Service: ServiceDeviceIO, RequiresInit: true, Category: "audio",
|
||||
Description: "Get audio outputs (DeviceIO)"},
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Aggregation Functions
|
||||
// =============================================================================
|
||||
|
||||
// AllReadOperations returns all READ operations across all services.
|
||||
func AllReadOperations() []OperationSpec {
|
||||
var all []OperationSpec
|
||||
all = append(all, DeviceReadOperations...)
|
||||
all = append(all, MediaReadOperations...)
|
||||
all = append(all, PTZReadOperations...)
|
||||
all = append(all, ImagingReadOperations...)
|
||||
all = append(all, EventReadOperations...)
|
||||
all = append(all, DeviceIOReadOperations...)
|
||||
return all
|
||||
}
|
||||
|
||||
// ReadOperationsByService returns READ operations for a specific service.
|
||||
func ReadOperationsByService(service ServiceType) []OperationSpec {
|
||||
switch service {
|
||||
case ServiceDevice:
|
||||
return DeviceReadOperations
|
||||
case ServiceMedia:
|
||||
return MediaReadOperations
|
||||
case ServicePTZ:
|
||||
return PTZReadOperations
|
||||
case ServiceImaging:
|
||||
return ImagingReadOperations
|
||||
case ServiceEvent:
|
||||
return EventReadOperations
|
||||
case ServiceDeviceIO:
|
||||
return DeviceIOReadOperations
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// IndependentOperations returns operations that don't depend on other operations.
|
||||
func IndependentOperations() []OperationSpec {
|
||||
var independent []OperationSpec
|
||||
for _, op := range AllReadOperations() {
|
||||
if op.DependsOn == "" {
|
||||
independent = append(independent, op)
|
||||
}
|
||||
}
|
||||
return independent
|
||||
}
|
||||
|
||||
// DependentOperations returns operations that depend on other operations.
|
||||
func DependentOperations() []OperationSpec {
|
||||
var dependent []OperationSpec
|
||||
for _, op := range AllReadOperations() {
|
||||
if op.DependsOn != "" {
|
||||
dependent = append(dependent, op)
|
||||
}
|
||||
}
|
||||
return dependent
|
||||
}
|
||||
|
||||
// OperationsByDependency returns operations grouped by their dependency.
|
||||
func OperationsByDependency(dependsOn string) []OperationSpec {
|
||||
var ops []OperationSpec
|
||||
for _, op := range AllReadOperations() {
|
||||
if op.DependsOn == dependsOn {
|
||||
ops = append(ops, op)
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
// GetOperationSpec finds an operation by name.
|
||||
func GetOperationSpec(name string) *OperationSpec {
|
||||
for i := range DeviceReadOperations {
|
||||
if DeviceReadOperations[i].Name == name {
|
||||
return &DeviceReadOperations[i]
|
||||
}
|
||||
}
|
||||
for i := range MediaReadOperations {
|
||||
if MediaReadOperations[i].Name == name {
|
||||
return &MediaReadOperations[i]
|
||||
}
|
||||
}
|
||||
for i := range PTZReadOperations {
|
||||
if PTZReadOperations[i].Name == name {
|
||||
return &PTZReadOperations[i]
|
||||
}
|
||||
}
|
||||
for i := range ImagingReadOperations {
|
||||
if ImagingReadOperations[i].Name == name {
|
||||
return &ImagingReadOperations[i]
|
||||
}
|
||||
}
|
||||
for i := range EventReadOperations {
|
||||
if EventReadOperations[i].Name == name {
|
||||
return &EventReadOperations[i]
|
||||
}
|
||||
}
|
||||
for i := range DeviceIOReadOperations {
|
||||
if DeviceIOReadOperations[i].Name == name {
|
||||
return &DeviceIOReadOperations[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OperationCount returns the count of operations by service.
|
||||
type OperationCount struct {
|
||||
Device int
|
||||
Media int
|
||||
PTZ int
|
||||
Imaging int
|
||||
Event int
|
||||
DeviceIO int
|
||||
Total int
|
||||
}
|
||||
|
||||
// GetOperationCount returns the count of READ operations.
|
||||
func GetOperationCount() OperationCount {
|
||||
return OperationCount{
|
||||
Device: len(DeviceReadOperations),
|
||||
Media: len(MediaReadOperations),
|
||||
PTZ: len(PTZReadOperations),
|
||||
Imaging: len(ImagingReadOperations),
|
||||
Event: len(EventReadOperations),
|
||||
DeviceIO: len(DeviceIOReadOperations),
|
||||
Total: len(AllReadOperations()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package onviftesting
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAllReadOperations(t *testing.T) {
|
||||
ops := AllReadOperations()
|
||||
|
||||
if len(ops) == 0 {
|
||||
t.Error("AllReadOperations should return operations")
|
||||
}
|
||||
|
||||
// Check we have significant coverage
|
||||
if len(ops) < 100 {
|
||||
t.Errorf("Expected at least 100 READ operations, got %d", len(ops))
|
||||
}
|
||||
|
||||
// Verify all operations have names
|
||||
for i, op := range ops {
|
||||
if op.Name == "" {
|
||||
t.Errorf("Operation %d has empty name", i)
|
||||
}
|
||||
if op.Service == "" {
|
||||
t.Errorf("Operation %s has empty service", op.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOperationCount(t *testing.T) {
|
||||
count := GetOperationCount()
|
||||
|
||||
if count.Total == 0 {
|
||||
t.Error("Total should be greater than 0")
|
||||
}
|
||||
|
||||
expectedTotal := count.Device + count.Media + count.PTZ + count.Imaging + count.Event + count.DeviceIO
|
||||
if count.Total != expectedTotal {
|
||||
t.Errorf("Total = %d, but sum of services = %d", count.Total, expectedTotal)
|
||||
}
|
||||
|
||||
// Verify we have operations in major services
|
||||
if count.Device == 0 {
|
||||
t.Error("Device operations should be > 0")
|
||||
}
|
||||
if count.Media == 0 {
|
||||
t.Error("Media operations should be > 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOperationsByService(t *testing.T) {
|
||||
tests := []struct {
|
||||
service ServiceType
|
||||
minOps int
|
||||
}{
|
||||
{ServiceDevice, 30},
|
||||
{ServiceMedia, 40},
|
||||
{ServicePTZ, 4},
|
||||
{ServiceImaging, 3},
|
||||
{ServiceEvent, 2},
|
||||
{ServiceDeviceIO, 8},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.service), func(t *testing.T) {
|
||||
ops := ReadOperationsByService(tt.service)
|
||||
if len(ops) < tt.minOps {
|
||||
t.Errorf("ReadOperationsByService(%s) returned %d ops, want at least %d",
|
||||
tt.service, len(ops), tt.minOps)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndependentOperations(t *testing.T) {
|
||||
independent := IndependentOperations()
|
||||
|
||||
if len(independent) == 0 {
|
||||
t.Error("IndependentOperations should return operations")
|
||||
}
|
||||
|
||||
// Verify all are actually independent
|
||||
for _, op := range independent {
|
||||
if op.DependsOn != "" {
|
||||
t.Errorf("Operation %s has DependsOn=%s but returned as independent",
|
||||
op.Name, op.DependsOn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDependentOperations(t *testing.T) {
|
||||
dependent := DependentOperations()
|
||||
|
||||
if len(dependent) == 0 {
|
||||
t.Error("DependentOperations should return operations")
|
||||
}
|
||||
|
||||
// Verify all are actually dependent
|
||||
for _, op := range dependent {
|
||||
if op.DependsOn == "" {
|
||||
t.Errorf("Operation %s has empty DependsOn but returned as dependent", op.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationsByDependency(t *testing.T) {
|
||||
// GetProfiles is a common dependency
|
||||
ops := OperationsByDependency("GetProfiles")
|
||||
|
||||
if len(ops) == 0 {
|
||||
t.Error("Operations depending on GetProfiles should exist")
|
||||
}
|
||||
|
||||
for _, op := range ops {
|
||||
if op.DependsOn != "GetProfiles" {
|
||||
t.Errorf("Operation %s has DependsOn=%s, want GetProfiles",
|
||||
op.Name, op.DependsOn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOperationSpec(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expected bool
|
||||
}{
|
||||
{"GetDeviceInformation", true},
|
||||
{"GetProfiles", true},
|
||||
{"GetStreamURI", true},
|
||||
{"GetStatus", true},
|
||||
{"NonExistentOperation", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
op := GetOperationSpec(tt.name)
|
||||
if tt.expected && op == nil {
|
||||
t.Errorf("GetOperationSpec(%s) returned nil, expected operation", tt.name)
|
||||
}
|
||||
if !tt.expected && op != nil {
|
||||
t.Errorf("GetOperationSpec(%s) returned operation, expected nil", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationSpec_DependencyChain(t *testing.T) {
|
||||
// Test that dependent operations reference valid dependencies
|
||||
dependent := DependentOperations()
|
||||
|
||||
for _, op := range dependent {
|
||||
depOp := GetOperationSpec(op.DependsOn)
|
||||
if depOp == nil {
|
||||
t.Errorf("Operation %s depends on %s which doesn't exist",
|
||||
op.Name, op.DependsOn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceReadOperations(t *testing.T) {
|
||||
// Check for expected core operations
|
||||
expectedOps := []string{
|
||||
"GetDeviceInformation",
|
||||
"GetCapabilities",
|
||||
"GetSystemDateAndTime",
|
||||
"GetHostname",
|
||||
"GetDNS",
|
||||
"GetNTP",
|
||||
"GetNetworkInterfaces",
|
||||
"GetScopes",
|
||||
"GetUsers",
|
||||
}
|
||||
|
||||
ops := DeviceReadOperations
|
||||
opMap := make(map[string]bool)
|
||||
for _, op := range ops {
|
||||
opMap[op.Name] = true
|
||||
}
|
||||
|
||||
for _, expected := range expectedOps {
|
||||
if !opMap[expected] {
|
||||
t.Errorf("Expected DeviceReadOperations to contain %s", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaReadOperations(t *testing.T) {
|
||||
// Check for expected core operations
|
||||
expectedOps := []string{
|
||||
"GetProfiles",
|
||||
"GetProfile",
|
||||
"GetVideoSources",
|
||||
"GetAudioSources",
|
||||
"GetStreamURI",
|
||||
"GetSnapshotURI",
|
||||
"GetVideoEncoderConfigurations",
|
||||
}
|
||||
|
||||
ops := MediaReadOperations
|
||||
opMap := make(map[string]bool)
|
||||
for _, op := range ops {
|
||||
opMap[op.Name] = true
|
||||
}
|
||||
|
||||
for _, expected := range expectedOps {
|
||||
if !opMap[expected] {
|
||||
t.Errorf("Expected MediaReadOperations to contain %s", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationCategories(t *testing.T) {
|
||||
ops := AllReadOperations()
|
||||
|
||||
// Check that all operations have categories
|
||||
for _, op := range ops {
|
||||
if op.Category == "" {
|
||||
t.Errorf("Operation %s has empty category", op.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for common categories
|
||||
categories := make(map[string]int)
|
||||
for _, op := range ops {
|
||||
categories[op.Category]++
|
||||
}
|
||||
|
||||
expectedCategories := []string{"core", "network", "profiles", "streaming"}
|
||||
for _, cat := range expectedCategories {
|
||||
if categories[cat] == 0 {
|
||||
t.Errorf("Expected category %s to have operations", cat)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
// Package onviftesting provides testing utilities for ONVIF client testing.
|
||||
package onviftesting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Registry holds information about all available camera captures.
|
||||
type Registry struct {
|
||||
Version string `json:"version"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
Cameras []CameraEntry `json:"cameras"`
|
||||
Coverage map[string]Coverage `json:"coverage"`
|
||||
}
|
||||
|
||||
// CameraEntry represents a single camera in the registry.
|
||||
type CameraEntry struct {
|
||||
ID string `json:"id"`
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Model string `json:"model"`
|
||||
Firmware string `json:"firmware"`
|
||||
CaptureFile string `json:"capture_file"`
|
||||
CaptureVersion string `json:"capture_version,omitempty"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
OperationsCaptured int `json:"operations_captured"`
|
||||
ProfileCompliance []string `json:"profile_compliance,omitempty"`
|
||||
TestFile string `json:"test_file,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
AddedDate string `json:"added_date,omitempty"`
|
||||
}
|
||||
|
||||
// Coverage tracks operation coverage per service.
|
||||
type Coverage struct {
|
||||
Total int `json:"total"`
|
||||
Captured int `json:"captured"`
|
||||
}
|
||||
|
||||
// RegistryVersion is the current registry format version.
|
||||
const RegistryVersion = "1.0"
|
||||
|
||||
// DefaultRegistryPath is the default path for the registry file.
|
||||
const DefaultRegistryPath = "testdata/captures/registry.json"
|
||||
|
||||
// LoadRegistry loads the capture registry from a file.
|
||||
func LoadRegistry(path string) (*Registry, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Return empty registry if file doesn't exist
|
||||
return &Registry{
|
||||
Version: RegistryVersion,
|
||||
LastUpdated: time.Now(),
|
||||
Cameras: []CameraEntry{},
|
||||
Coverage: make(map[string]Coverage),
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read registry: %w", err)
|
||||
}
|
||||
|
||||
var registry Registry
|
||||
if err := json.Unmarshal(data, ®istry); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal registry: %w", err)
|
||||
}
|
||||
|
||||
return ®istry, nil
|
||||
}
|
||||
|
||||
// SaveRegistry saves the registry to a file.
|
||||
func SaveRegistry(registry *Registry, path string) error {
|
||||
registry.LastUpdated = time.Now()
|
||||
|
||||
data, err := json.MarshalIndent(registry, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal registry: %w", err)
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0750); err != nil { //nolint:mnd
|
||||
return fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0600); err != nil { //nolint:mnd
|
||||
return fmt.Errorf("failed to write registry: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddCamera adds a new camera to the registry.
|
||||
func (r *Registry) AddCamera(entry CameraEntry) {
|
||||
// Check if camera already exists
|
||||
for i, cam := range r.Cameras {
|
||||
if cam.ID == entry.ID {
|
||||
// Update existing entry
|
||||
r.Cameras[i] = entry
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Add new entry
|
||||
if entry.AddedDate == "" {
|
||||
entry.AddedDate = time.Now().Format("2006-01-02")
|
||||
}
|
||||
r.Cameras = append(r.Cameras, entry)
|
||||
}
|
||||
|
||||
// GetCamera retrieves a camera entry by ID.
|
||||
func (r *Registry) GetCamera(id string) *CameraEntry {
|
||||
for i := range r.Cameras {
|
||||
if r.Cameras[i].ID == id {
|
||||
return &r.Cameras[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveCamera removes a camera from the registry.
|
||||
func (r *Registry) RemoveCamera(id string) bool {
|
||||
for i, cam := range r.Cameras {
|
||||
if cam.ID == id {
|
||||
r.Cameras = append(r.Cameras[:i], r.Cameras[i+1:]...)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetCamerasByManufacturer returns all cameras from a specific manufacturer.
|
||||
func (r *Registry) GetCamerasByManufacturer(manufacturer string) []CameraEntry {
|
||||
var cameras []CameraEntry
|
||||
for _, cam := range r.Cameras {
|
||||
if cam.Manufacturer == manufacturer {
|
||||
cameras = append(cameras, cam)
|
||||
}
|
||||
}
|
||||
return cameras
|
||||
}
|
||||
|
||||
// UpdateCoverage updates the coverage statistics based on registered cameras.
|
||||
func (r *Registry) UpdateCoverage() {
|
||||
// Define total operations per service
|
||||
totals := map[string]int{
|
||||
"Device": len(DeviceReadOperations),
|
||||
"Media": len(MediaReadOperations),
|
||||
"PTZ": len(PTZReadOperations),
|
||||
"Imaging": len(ImagingReadOperations),
|
||||
"Event": len(EventReadOperations),
|
||||
"DeviceIO": len(DeviceIOReadOperations),
|
||||
}
|
||||
|
||||
// Initialize coverage
|
||||
r.Coverage = make(map[string]Coverage)
|
||||
for service, total := range totals {
|
||||
r.Coverage[service] = Coverage{
|
||||
Total: total,
|
||||
Captured: 0, // Would need to analyze captures to determine actual coverage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetTotalCoverage returns the total coverage across all services.
|
||||
func (r *Registry) GetTotalCoverage() (total int, captured int) {
|
||||
for _, cov := range r.Coverage {
|
||||
total += cov.Total
|
||||
captured += cov.Captured
|
||||
}
|
||||
return total, captured
|
||||
}
|
||||
|
||||
// GenerateCameraID generates a unique ID for a camera.
|
||||
func GenerateCameraID(manufacturer, model, firmware string) string {
|
||||
// Sanitize and combine
|
||||
id := fmt.Sprintf("%s_%s_%s", manufacturer, model, firmware)
|
||||
id = sanitizeID(id)
|
||||
return id
|
||||
}
|
||||
|
||||
// sanitizeID removes or replaces invalid characters in an ID.
|
||||
func sanitizeID(s string) string {
|
||||
result := make([]byte, 0, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z':
|
||||
result = append(result, c)
|
||||
case c >= 'A' && c <= 'Z':
|
||||
result = append(result, c+'a'-'A') // lowercase
|
||||
case c >= '0' && c <= '9':
|
||||
result = append(result, c)
|
||||
case c == ' ' || c == '-' || c == '_' || c == '.':
|
||||
result = append(result, '_')
|
||||
}
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// ValidateRegistry checks if all referenced capture files exist.
|
||||
func ValidateRegistry(registry *Registry, basePath string) []string {
|
||||
var errors []string
|
||||
|
||||
for _, cam := range registry.Cameras {
|
||||
capturePath := filepath.Join(basePath, cam.CaptureFile)
|
||||
if _, err := os.Stat(capturePath); os.IsNotExist(err) {
|
||||
errors = append(errors, fmt.Sprintf("camera %s: capture file not found: %s", cam.ID, cam.CaptureFile))
|
||||
}
|
||||
|
||||
if cam.TestFile != "" {
|
||||
testPath := filepath.Join(basePath, cam.TestFile)
|
||||
if _, err := os.Stat(testPath); os.IsNotExist(err) {
|
||||
errors = append(errors, fmt.Sprintf("camera %s: test file not found: %s", cam.ID, cam.TestFile))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// CreateCameraEntryFromCapture creates a registry entry from a capture archive.
|
||||
func CreateCameraEntryFromCapture(archivePath string) (*CameraEntry, error) {
|
||||
capture, metadata, err := LoadCaptureFromArchiveV2(archivePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Extract camera info
|
||||
var cameraInfo CameraInfo
|
||||
if metadata != nil {
|
||||
cameraInfo = metadata.CameraInfo
|
||||
} else {
|
||||
// Try to extract from GetDeviceInformation response
|
||||
for _, ex := range capture.Exchanges {
|
||||
if ex.OperationName == "GetDeviceInformation" {
|
||||
cameraInfo.Manufacturer = ExtractXMLElement(ex.ResponseBody, "Manufacturer")
|
||||
cameraInfo.Model = ExtractXMLElement(ex.ResponseBody, "Model")
|
||||
cameraInfo.FirmwareVersion = ExtractXMLElement(ex.ResponseBody, "FirmwareVersion")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine capabilities from captured operations
|
||||
capabilities := detectCapabilities(capture)
|
||||
|
||||
entry := &CameraEntry{
|
||||
ID: GenerateCameraID(cameraInfo.Manufacturer, cameraInfo.Model, cameraInfo.FirmwareVersion),
|
||||
Manufacturer: cameraInfo.Manufacturer,
|
||||
Model: cameraInfo.Model,
|
||||
Firmware: cameraInfo.FirmwareVersion,
|
||||
CaptureFile: filepath.Base(archivePath),
|
||||
OperationsCaptured: len(capture.Exchanges),
|
||||
Capabilities: capabilities,
|
||||
AddedDate: time.Now().Format("2006-01-02"),
|
||||
}
|
||||
|
||||
if metadata != nil {
|
||||
entry.CaptureVersion = metadata.Version
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// detectCapabilities determines which services are captured.
|
||||
func detectCapabilities(capture *CameraCaptureV2) []string {
|
||||
services := make(map[string]bool)
|
||||
|
||||
for _, ex := range capture.Exchanges {
|
||||
if ex.ServiceType != "" {
|
||||
services[string(ex.ServiceType)] = true
|
||||
} else {
|
||||
// Infer from operation name
|
||||
svc := inferServiceFromOperation(ex.OperationName)
|
||||
if svc != "" {
|
||||
services[svc] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result []string
|
||||
for svc := range services {
|
||||
result = append(result, svc)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// inferServiceFromOperation guesses the service type from an operation name.
|
||||
func inferServiceFromOperation(op string) string {
|
||||
// Media operations typically have these patterns
|
||||
mediaOps := []string{"Profile", "Stream", "Encoder", "VideoSource", "AudioSource", "OSD", "Metadata"}
|
||||
for _, pattern := range mediaOps {
|
||||
if containsSubstring(op, pattern) {
|
||||
return "Media"
|
||||
}
|
||||
}
|
||||
|
||||
// PTZ operations
|
||||
if containsSubstring(op, "PTZ") || containsSubstring(op, "Preset") || containsSubstring(op, "Move") {
|
||||
return "PTZ"
|
||||
}
|
||||
|
||||
// Imaging operations
|
||||
if containsSubstring(op, "Imaging") || op == "GetOptions" || op == "GetMoveOptions" {
|
||||
return "Imaging"
|
||||
}
|
||||
|
||||
// Event operations
|
||||
if containsSubstring(op, "Event") || containsSubstring(op, "Subscription") {
|
||||
return "Event"
|
||||
}
|
||||
|
||||
// Default to Device
|
||||
return "Device"
|
||||
}
|
||||
|
||||
// containsSubstring checks if s contains substr (case-sensitive).
|
||||
func containsSubstring(s, substr string) bool {
|
||||
return len(s) >= len(substr) && findSubstring(s, substr) >= 0
|
||||
}
|
||||
|
||||
// findSubstring finds substr in s, returns -1 if not found.
|
||||
func findSubstring(s, substr string) int {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// RegistrySummary provides a summary of the registry.
|
||||
type RegistrySummary struct {
|
||||
TotalCameras int
|
||||
TotalOperations int
|
||||
CapturedOperations int
|
||||
ManufacturerCount map[string]int
|
||||
ServiceCoverage map[string]float64
|
||||
}
|
||||
|
||||
// GetSummary generates a summary of the registry.
|
||||
func (r *Registry) GetSummary() RegistrySummary {
|
||||
summary := RegistrySummary{
|
||||
TotalCameras: len(r.Cameras),
|
||||
ManufacturerCount: make(map[string]int),
|
||||
ServiceCoverage: make(map[string]float64),
|
||||
}
|
||||
|
||||
// Count by manufacturer
|
||||
for _, cam := range r.Cameras {
|
||||
summary.ManufacturerCount[cam.Manufacturer]++
|
||||
}
|
||||
|
||||
// Calculate coverage percentages
|
||||
for service, cov := range r.Coverage {
|
||||
summary.TotalOperations += cov.Total
|
||||
summary.CapturedOperations += cov.Captured
|
||||
if cov.Total > 0 {
|
||||
summary.ServiceCoverage[service] = float64(cov.Captured) / float64(cov.Total) * 100
|
||||
}
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
Reference in New Issue
Block a user