Merge pull request #47 from 0x524a/46-feature-add-more-event-operations

feat: implement Event and Device IO services with CLI integration
This commit is contained in:
ProtoTess
2025-12-03 01:06:33 -05:00
committed by GitHub
7 changed files with 4184 additions and 1 deletions
+10
View File
@@ -99,6 +99,16 @@ linters:
linters: linters:
- dupl - dupl
- path: deviceio\.go
linters:
- dupl
- path: event\.go
linters:
- dupl
- gocritic
- staticcheck
- path: examples/ - path: examples/
linters: linters:
- errcheck - errcheck
+589 -1
View File
@@ -58,6 +58,10 @@ func main() {
cli.ptzOperations() cli.ptzOperations()
case "6": case "6":
cli.imagingOperations() cli.imagingOperations()
case "7":
cli.eventOperations()
case "8":
cli.deviceIOOperations()
case "0", "q", "quit", "exit": case "0", "q", "quit", "exit":
fmt.Println("Goodbye! 👋") fmt.Println("Goodbye! 👋")
@@ -78,8 +82,10 @@ func (c *CLI) showMainMenu() {
fmt.Println(" 4. Media Operations") fmt.Println(" 4. Media Operations")
fmt.Println(" 5. PTZ Operations") fmt.Println(" 5. PTZ Operations")
fmt.Println(" 6. Imaging Operations") fmt.Println(" 6. Imaging Operations")
fmt.Println(" 7. Event Operations")
fmt.Println(" 8. Device IO Operations")
} else { } else {
fmt.Println(" 3-6. (Connect to camera first)") fmt.Println(" 3-8. (Connect to camera first)")
} }
fmt.Println(" 0. Exit") fmt.Println(" 0. Exit")
fmt.Println() fmt.Println()
@@ -1625,3 +1631,585 @@ func (c *CLI) captureAndDisplaySnapshot(ctx context.Context) { //nolint:funlen /
} }
} }
} }
// ============================================
// Event Operations
// ============================================
func (c *CLI) eventOperations() {
if c.client == nil {
fmt.Println("❌ Not connected to any camera")
return
}
fmt.Println("📡 Event Operations")
fmt.Println("==================")
fmt.Println(" 1. Get Event Service Capabilities")
fmt.Println(" 2. Get Event Properties")
fmt.Println(" 3. Create Pull Point Subscription")
fmt.Println(" 4. Get Event Brokers")
fmt.Println(" 0. Back to Main Menu")
choice := c.readInput("Select operation: ")
ctx := context.Background()
switch choice {
case "1":
c.getEventServiceCapabilities(ctx)
case "2":
c.getEventProperties(ctx)
case "3":
c.createPullPointSubscription(ctx)
case "4":
c.getEventBrokers(ctx)
case "0":
return
default:
fmt.Println("❌ Invalid option")
}
}
func (c *CLI) getEventServiceCapabilities(ctx context.Context) {
fmt.Println("⏳ Getting event service capabilities...")
caps, err := c.client.GetEventServiceCapabilities(ctx)
if err != nil {
fmt.Printf("❌ Error: %v\n", err)
return
}
fmt.Println("✅ Event Service Capabilities:")
fmt.Printf(" WS Subscription Policy Support: %v\n", caps.WSSubscriptionPolicySupport)
fmt.Printf(" WS Pausable Subscription: %v\n", caps.WSPausableSubscriptionManagerInterfaceSupport)
fmt.Printf(" Max Notification Producers: %d\n", caps.MaxNotificationProducers)
fmt.Printf(" Max Pull Points: %d\n", caps.MaxPullPoints)
fmt.Printf(" Persistent Notification Storage: %v\n", caps.PersistentNotificationStorage)
fmt.Printf(" Event Broker Protocols: %v\n", caps.EventBrokerProtocols)
fmt.Printf(" Max Event Brokers: %d\n", caps.MaxEventBrokers)
fmt.Printf(" Metadata Over MQTT: %v\n", caps.MetadataOverMQTT)
}
func (c *CLI) getEventProperties(ctx context.Context) {
fmt.Println("⏳ Getting event properties...")
props, err := c.client.GetEventProperties(ctx)
if err != nil {
fmt.Printf("❌ Error: %v\n", err)
return
}
fmt.Println("✅ Event Properties:")
fmt.Printf(" Fixed Topic Set: %v\n", props.FixedTopicSet)
fmt.Printf(" Topic Namespace Locations: %d\n", len(props.TopicNamespaceLocation))
for i, loc := range props.TopicNamespaceLocation {
fmt.Printf(" %d. %s\n", i+1, loc)
}
fmt.Printf(" Topic Expression Dialects: %d\n", len(props.TopicExpressionDialects))
fmt.Printf(" Message Content Filter Dialects: %d\n", len(props.MessageContentFilterDialects))
}
func (c *CLI) createPullPointSubscription(ctx context.Context) {
fmt.Println("⏳ Creating pull point subscription...")
termTimeStr := c.readInputWithDefault("Subscription duration (seconds)", "60")
termTimeSec, err := strconv.Atoi(termTimeStr)
if err != nil || termTimeSec <= 0 {
termTimeSec = 60
}
termTime := time.Duration(termTimeSec) * time.Second
sub, err := c.client.CreatePullPointSubscription(ctx, "", &termTime, "")
if err != nil {
fmt.Printf("❌ Error: %v\n", err)
return
}
fmt.Println("✅ Pull Point Subscription Created:")
fmt.Printf(" Subscription Reference: %s\n", sub.SubscriptionReference)
fmt.Printf(" Current Time: %v\n", sub.CurrentTime)
fmt.Printf(" Termination Time: %v\n", sub.TerminationTime)
// Offer to pull messages
pull := c.readInput("📨 Pull messages now? (y/n) [y]: ")
if pull == "" || strings.EqualFold(pull, "y") {
c.pullMessagesFromSubscription(ctx, sub.SubscriptionReference)
}
// Offer to unsubscribe
unsub := c.readInput("🔌 Unsubscribe? (y/n) [y]: ")
if unsub == "" || strings.EqualFold(unsub, "y") {
if err := c.client.Unsubscribe(ctx, sub.SubscriptionReference); err != nil {
fmt.Printf("❌ Unsubscribe error: %v\n", err)
} else {
fmt.Println("✅ Unsubscribed successfully")
}
}
}
func (c *CLI) pullMessagesFromSubscription(ctx context.Context, subscriptionRef string) {
fmt.Println("⏳ Pulling messages (5 second timeout)...")
messages, err := c.client.PullMessages(ctx, subscriptionRef, 5*time.Second, 100) //nolint:mnd // 100 max messages
if err != nil {
fmt.Printf("❌ Error: %v\n", err)
return
}
if len(messages) == 0 {
fmt.Println("📭 No messages available")
return
}
fmt.Printf("✅ Received %d message(s):\n", len(messages))
for i := range messages {
msg := &messages[i]
if i >= 10 { //nolint:mnd // Show max 10 messages
fmt.Printf(" ... and %d more\n", len(messages)-10) //nolint:mnd // Show remaining count
break
}
fmt.Printf(" %d. Topic: %s\n", i+1, msg.Topic)
if msg.Message.PropertyOperation != "" {
fmt.Printf(" Operation: %s\n", msg.Message.PropertyOperation)
}
if !msg.Message.UtcTime.IsZero() {
fmt.Printf(" Time: %v\n", msg.Message.UtcTime)
}
if len(msg.Message.Source) > 0 {
fmt.Printf(" Source: %s=%s\n", msg.Message.Source[0].Name, msg.Message.Source[0].Value)
}
if len(msg.Message.Data) > 0 {
fmt.Printf(" Data: %s=%s\n", msg.Message.Data[0].Name, msg.Message.Data[0].Value)
}
}
}
func (c *CLI) getEventBrokers(ctx context.Context) {
fmt.Println("⏳ Getting event brokers...")
brokers, err := c.client.GetEventBrokers(ctx)
if err != nil {
fmt.Printf("❌ Error: %v\n", err)
return
}
if len(brokers) == 0 {
fmt.Println("📭 No event brokers configured")
return
}
fmt.Printf("✅ Found %d Event Broker(s):\n", len(brokers))
for i, broker := range brokers {
fmt.Printf(" %d. Address: %s\n", i+1, broker.Address)
if broker.TopicPrefix != "" {
fmt.Printf(" Topic Prefix: %s\n", broker.TopicPrefix)
}
if broker.Status != "" {
fmt.Printf(" Status: %s\n", broker.Status)
}
fmt.Printf(" QoS: %d\n", broker.QoS)
}
}
// ============================================
// Device IO Operations
// ============================================
func (c *CLI) deviceIOOperations() {
if c.client == nil {
fmt.Println("❌ Not connected to any camera")
return
}
fmt.Println("🔌 Device IO Operations")
fmt.Println("======================")
fmt.Println(" 1. Get Device IO Capabilities")
fmt.Println(" 2. Get Digital Inputs")
fmt.Println(" 3. Get Relay Outputs")
fmt.Println(" 4. Set Relay Output State")
fmt.Println(" 5. Get Relay Output Options")
fmt.Println(" 6. Get Video Outputs")
fmt.Println(" 7. Get Video Output Configuration")
fmt.Println(" 8. Get Video Output Configuration Options")
fmt.Println(" 9. Get Serial Ports")
fmt.Println(" 0. Back to Main Menu")
choice := c.readInput("Select operation: ")
ctx := context.Background()
switch choice {
case "1":
c.getDeviceIOCapabilities(ctx)
case "2":
c.getDigitalInputs(ctx)
case "3":
c.getRelayOutputsCLI(ctx)
case "4":
c.setRelayOutputStateCLI(ctx)
case "5":
c.getRelayOutputOptionsCLI(ctx)
case "6":
c.getVideoOutputsCLI(ctx)
case "7":
c.getVideoOutputConfigurationCLI(ctx)
case "8":
c.getVideoOutputConfigurationOptionsCLI(ctx)
case "9":
c.getSerialPortsCLI(ctx)
case "0":
return
default:
fmt.Println("❌ Invalid option")
}
}
func (c *CLI) getDeviceIOCapabilities(ctx context.Context) {
fmt.Println("⏳ Getting Device IO capabilities...")
caps, err := c.client.GetDeviceIOServiceCapabilities(ctx)
if err != nil {
fmt.Printf("❌ Error: %v\n", err)
return
}
fmt.Println("✅ Device IO Capabilities:")
fmt.Printf(" Video Sources: %d\n", caps.VideoSources)
fmt.Printf(" Video Outputs: %d\n", caps.VideoOutputs)
fmt.Printf(" Audio Sources: %d\n", caps.AudioSources)
fmt.Printf(" Audio Outputs: %d\n", caps.AudioOutputs)
fmt.Printf(" Relay Outputs: %d\n", caps.RelayOutputs)
fmt.Printf(" Digital Inputs: %d\n", caps.DigitalInputs)
fmt.Printf(" Serial Ports: %d\n", caps.SerialPorts)
fmt.Printf(" Digital Input Options: %v\n", caps.DigitalInputOptions)
fmt.Printf(" Serial Port Configuration: %v\n", caps.SerialPortConfiguration)
}
func (c *CLI) getDigitalInputs(ctx context.Context) {
fmt.Println("⏳ Getting digital inputs...")
inputs, err := c.client.GetDigitalInputs(ctx)
if err != nil {
fmt.Printf("❌ Error: %v\n", err)
return
}
if len(inputs) == 0 {
fmt.Println("📭 No digital inputs found")
return
}
fmt.Printf("✅ Found %d Digital Input(s):\n", len(inputs))
for i, input := range inputs {
fmt.Printf(" %d. Token: %s\n", i+1, input.Token)
fmt.Printf(" Idle State: %s\n", input.IdleState)
}
}
func (c *CLI) getRelayOutputsCLI(ctx context.Context) {
fmt.Println("⏳ Getting relay outputs...")
relays, err := c.client.GetRelayOutputs(ctx)
if err != nil {
fmt.Printf("❌ Error: %v\n", err)
return
}
if len(relays) == 0 {
fmt.Println("📭 No relay outputs found")
return
}
fmt.Printf("✅ Found %d Relay Output(s):\n", len(relays))
for i, relay := range relays {
fmt.Printf(" %d. Token: %s\n", i+1, relay.Token)
fmt.Printf(" Mode: %s\n", relay.Properties.Mode)
fmt.Printf(" Idle State: %s\n", relay.Properties.IdleState)
if relay.Properties.DelayTime > 0 {
fmt.Printf(" Delay Time: %v\n", relay.Properties.DelayTime)
}
}
}
func (c *CLI) setRelayOutputStateCLI(ctx context.Context) {
// First get available relay outputs
relays, err := c.client.GetRelayOutputs(ctx)
if err != nil {
fmt.Printf("❌ Error getting relays: %v\n", err)
return
}
if len(relays) == 0 {
fmt.Println("📭 No relay outputs available")
return
}
fmt.Println("Available relay outputs:")
for i, relay := range relays {
fmt.Printf(" %d. %s (Mode: %s)\n", i+1, relay.Token, relay.Properties.Mode)
}
choice := c.readInput("Select relay (1-" + strconv.Itoa(len(relays)) + "): ")
idx, err := strconv.Atoi(choice)
if err != nil || idx < 1 || idx > len(relays) {
fmt.Println("❌ Invalid selection")
return
}
selectedRelay := relays[idx-1]
fmt.Println("Select state:")
fmt.Println(" 1. Active")
fmt.Println(" 2. Inactive")
stateChoice := c.readInput("State: ")
var state onvif.RelayLogicalState
switch stateChoice {
case "1":
state = onvif.RelayLogicalStateActive
case "2":
state = onvif.RelayLogicalStateInactive
default:
fmt.Println("❌ Invalid state")
return
}
fmt.Println("⏳ Setting relay output state...")
if err := c.client.SetRelayOutputState(ctx, selectedRelay.Token, state); err != nil {
fmt.Printf("❌ Error: %v\n", err)
return
}
fmt.Printf("✅ Relay %s set to %s\n", selectedRelay.Token, state)
}
func (c *CLI) getVideoOutputsCLI(ctx context.Context) {
fmt.Println("⏳ Getting video outputs...")
outputs, err := c.client.GetVideoOutputs(ctx)
if err != nil {
fmt.Printf("❌ Error: %v\n", err)
return
}
if len(outputs) == 0 {
fmt.Println("📭 No video outputs found")
return
}
fmt.Printf("✅ Found %d Video Output(s):\n", len(outputs))
for i, output := range outputs {
fmt.Printf(" %d. Token: %s\n", i+1, output.Token)
if output.Resolution != nil {
fmt.Printf(" Resolution: %dx%d\n", output.Resolution.Width, output.Resolution.Height)
}
if output.RefreshRate > 0 {
fmt.Printf(" Refresh Rate: %.1f Hz\n", output.RefreshRate)
}
if output.AspectRatio != "" {
fmt.Printf(" Aspect Ratio: %s\n", output.AspectRatio)
}
}
}
func (c *CLI) getSerialPortsCLI(ctx context.Context) {
fmt.Println("⏳ Getting serial ports...")
ports, err := c.client.GetSerialPorts(ctx)
if err != nil {
fmt.Printf("❌ Error: %v\n", err)
return
}
if len(ports) == 0 {
fmt.Println("📭 No serial ports found")
return
}
fmt.Printf("✅ Found %d Serial Port(s):\n", len(ports))
for i, port := range ports {
fmt.Printf(" %d. Token: %s\n", i+1, port.Token)
fmt.Printf(" Type: %s\n", port.Type)
// Get configuration if available
config, err := c.client.GetSerialPortConfiguration(ctx, port.Token)
if err == nil {
fmt.Printf(" Baud Rate: %d\n", config.BaudRate)
fmt.Printf(" Parity: %s\n", config.ParityBit)
fmt.Printf(" Data Bits: %d\n", config.CharacterLength)
fmt.Printf(" Stop Bits: %.1f\n", config.StopBit)
}
}
}
func (c *CLI) getRelayOutputOptionsCLI(ctx context.Context) {
// First get available relay outputs
relays, err := c.client.GetRelayOutputs(ctx)
if err != nil {
fmt.Printf("❌ Error getting relays: %v\n", err)
return
}
if len(relays) == 0 {
fmt.Println("📭 No relay outputs available")
return
}
fmt.Println("Available relay outputs:")
for i, relay := range relays {
fmt.Printf(" %d. %s\n", i+1, relay.Token)
}
choice := c.readInput("Select relay (1-" + strconv.Itoa(len(relays)) + "): ")
idx, err := strconv.Atoi(choice)
if err != nil || idx < 1 || idx > len(relays) {
fmt.Println("❌ Invalid selection")
return
}
selectedRelay := relays[idx-1]
fmt.Printf("⏳ Getting relay output options for %s...\n", selectedRelay.Token)
options, err := c.client.GetRelayOutputOptions(ctx, selectedRelay.Token)
if err != nil {
fmt.Printf("❌ Error: %v\n", err)
return
}
fmt.Println("✅ Relay Output Options:")
fmt.Printf(" Token: %s\n", options.Token)
if len(options.Mode) > 0 {
fmt.Println(" Supported Modes:")
for _, mode := range options.Mode {
fmt.Printf(" - %s\n", mode)
}
}
if len(options.DelayTimes) > 0 {
fmt.Println(" Supported Delay Times:")
for _, dt := range options.DelayTimes {
fmt.Printf(" - %s\n", dt)
}
}
fmt.Printf(" Discrete: %v\n", options.Discrete)
}
func (c *CLI) getVideoOutputConfigurationCLI(ctx context.Context) {
// First get available video outputs
outputs, err := c.client.GetVideoOutputs(ctx)
if err != nil {
fmt.Printf("❌ Error getting video outputs: %v\n", err)
return
}
if len(outputs) == 0 {
fmt.Println("📭 No video outputs available")
return
}
fmt.Println("Available video outputs:")
for i, output := range outputs {
fmt.Printf(" %d. %s\n", i+1, output.Token)
}
choice := c.readInput("Select video output (1-" + strconv.Itoa(len(outputs)) + "): ")
idx, err := strconv.Atoi(choice)
if err != nil || idx < 1 || idx > len(outputs) {
fmt.Println("❌ Invalid selection")
return
}
selectedOutput := outputs[idx-1]
fmt.Printf("⏳ Getting video output configuration for %s...\n", selectedOutput.Token)
config, err := c.client.GetVideoOutputConfiguration(ctx, selectedOutput.Token)
if err != nil {
fmt.Printf("❌ Error: %v\n", err)
return
}
fmt.Println("✅ Video Output Configuration:")
fmt.Printf(" Token: %s\n", config.Token)
fmt.Printf(" Name: %s\n", config.Name)
fmt.Printf(" Use Count: %d\n", config.UseCount)
fmt.Printf(" Output Token: %s\n", config.OutputToken)
}
func (c *CLI) getVideoOutputConfigurationOptionsCLI(ctx context.Context) {
// First get available video outputs
outputs, err := c.client.GetVideoOutputs(ctx)
if err != nil {
fmt.Printf("❌ Error getting video outputs: %v\n", err)
return
}
if len(outputs) == 0 {
fmt.Println("📭 No video outputs available")
return
}
fmt.Println("Available video outputs:")
for i, output := range outputs {
fmt.Printf(" %d. %s\n", i+1, output.Token)
}
choice := c.readInput("Select video output (1-" + strconv.Itoa(len(outputs)) + "): ")
idx, err := strconv.Atoi(choice)
if err != nil || idx < 1 || idx > len(outputs) {
fmt.Println("❌ Invalid selection")
return
}
selectedOutput := outputs[idx-1]
fmt.Printf("⏳ Getting video output configuration options for %s...\n", selectedOutput.Token)
options, err := c.client.GetVideoOutputConfigurationOptions(ctx, selectedOutput.Token)
if err != nil {
fmt.Printf("❌ Error: %v\n", err)
return
}
fmt.Println("✅ Video Output Configuration Options:")
fmt.Printf(" Name Length: Min=%d, Max=%d\n", options.Name.Min, options.Name.Max)
if len(options.OutputTokensAvailable) > 0 {
fmt.Println(" Available Output Tokens:")
for _, token := range options.OutputTokensAvailable {
fmt.Printf(" - %s\n", token)
}
}
}
+912
View File
@@ -0,0 +1,912 @@
package onvif
import (
"context"
"encoding/xml"
"errors"
"fmt"
"github.com/0x524a/onvif-go/internal/soap"
)
// Device IO service namespace.
const deviceIONamespace = "http://www.onvif.org/ver10/deviceIO/wsdl"
// Device IO service errors.
var (
// ErrInvalidDigitalInputToken is returned when digital input token is invalid.
ErrInvalidDigitalInputToken = errors.New("invalid digital input token: cannot be empty")
// ErrInvalidVideoOutputToken is returned when video output token is invalid.
ErrInvalidVideoOutputToken = errors.New("invalid video output token: cannot be empty")
// ErrInvalidSerialPortToken is returned when serial port token is invalid.
ErrInvalidSerialPortToken = errors.New("invalid serial port token: cannot be empty")
// ErrInvalidSerialData is returned when serial data is invalid.
ErrInvalidSerialData = errors.New("invalid serial data: cannot be empty")
// ErrDigitalInputConfigNil is returned when digital input config is nil.
ErrDigitalInputConfigNil = errors.New("digital input config cannot be nil")
// ErrSerialPortConfigNil is returned when serial port config is nil.
ErrSerialPortConfigNil = errors.New("serial port config cannot be nil")
// ErrVideoOutputConfigNil is returned when video output config is nil.
ErrVideoOutputConfigNil = errors.New("video output configuration cannot be nil")
// ErrInvalidRelayOutputToken is returned when relay output token is invalid.
ErrInvalidRelayOutputToken = errors.New("invalid relay output token: cannot be empty")
)
// DeviceIOServiceCapabilities represents the capabilities of the device IO service.
type DeviceIOServiceCapabilities struct {
VideoSources int
VideoOutputs int
AudioSources int
AudioOutputs int
RelayOutputs int
SerialPorts int
DigitalInputs int
DigitalInputOptions bool
SerialPortConfiguration bool
}
// DigitalInput represents a digital input.
type DigitalInput struct {
Token string
IdleState DigitalIdleState
}
// DigitalIdleState represents the idle state of a digital input.
type DigitalIdleState string
// Digital idle state constants.
const (
DigitalIdleOpen DigitalIdleState = "open"
DigitalIdleClosed DigitalIdleState = "closed"
)
// VideoOutput represents a video output.
type VideoOutput struct {
Token string
Layout *Layout
Resolution *VideoResolution
RefreshRate float64
AspectRatio string
}
// Layout represents a video output layout.
type Layout struct {
Pane []PaneLayout
Extension interface{}
}
// PaneLayout represents a pane layout.
type PaneLayout struct {
Pane string
Area FloatRectangle
}
// FloatRectangle represents a floating point rectangle.
type FloatRectangle struct {
Bottom float64
Top float64
Right float64
Left float64
}
// SerialPort represents a serial port.
type SerialPort struct {
Token string
Type SerialPortType
}
// SerialPortType represents the type of a serial port.
type SerialPortType string
// Serial port type constants.
const (
SerialPortTypeRS232 SerialPortType = "RS232"
SerialPortTypeRS422 SerialPortType = "RS422"
SerialPortTypeRS485 SerialPortType = "RS485"
SerialPortTypeGeneric SerialPortType = "Generic"
)
// SerialPortConfiguration represents a serial port configuration.
type SerialPortConfiguration struct {
Token string
Type SerialPortType
BaudRate int
ParityBit ParityBit
CharacterLength int
StopBit float64
}
// ParityBit represents the parity bit setting.
type ParityBit string
// Parity bit constants.
const (
ParityNone ParityBit = "None"
ParityOdd ParityBit = "Odd"
ParityEven ParityBit = "Even"
ParityMark ParityBit = "Mark"
ParitySpace ParityBit = "Space"
)
// SerialPortConfigurationOptions represents serial port configuration options.
type SerialPortConfigurationOptions struct {
Token string
BaudRateList []int
ParityBitList []ParityBit
CharacterLengthList []int
StopBitList []float64
}
// DigitalInputConfigurationOptions represents digital input configuration options.
type DigitalInputConfigurationOptions struct {
IdleStateOptions []DigitalIdleState
}
// VideoOutputConfiguration represents a video output configuration.
type VideoOutputConfiguration struct {
Token string
Name string
UseCount int
OutputToken string
ForcePersistence bool
}
// VideoOutputConfigurationOptions represents video output configuration options.
type VideoOutputConfigurationOptions struct {
Name StringRange
OutputTokensAvailable []string
}
// StringRange represents a range of string values.
type StringRange struct {
Min int
Max int
}
// RelayOutputOptions represents relay output configuration options.
type RelayOutputOptions struct {
Token string
Mode []RelayMode
DelayTimes []string
Discrete bool
}
// getDeviceIOEndpoint returns the device IO endpoint.
func (c *Client) getDeviceIOEndpoint() string {
// Device IO typically uses the main device endpoint.
return c.endpoint
}
// GetDeviceIOServiceCapabilities retrieves the capabilities of the device IO service.
func (c *Client) GetDeviceIOServiceCapabilities(ctx context.Context) (*DeviceIOServiceCapabilities, error) {
endpoint := c.getDeviceIOEndpoint()
type GetServiceCapabilities struct {
XMLName xml.Name `xml:"tmd:GetServiceCapabilities"`
Xmlns string `xml:"xmlns:tmd,attr"`
}
type GetServiceCapabilitiesResponse struct {
XMLName xml.Name `xml:"GetServiceCapabilitiesResponse"`
Capabilities struct {
VideoSources int `xml:"VideoSources,attr"`
VideoOutputs int `xml:"VideoOutputs,attr"`
AudioSources int `xml:"AudioSources,attr"`
AudioOutputs int `xml:"AudioOutputs,attr"`
RelayOutputs int `xml:"RelayOutputs,attr"`
SerialPorts int `xml:"SerialPorts,attr"`
DigitalInputs int `xml:"DigitalInputs,attr"`
DigitalInputOptions bool `xml:"DigitalInputOptions,attr"`
SerialPortConfiguration bool `xml:"SerialPortConfiguration,attr"`
} `xml:"Capabilities"`
}
req := GetServiceCapabilities{
Xmlns: deviceIONamespace,
}
var resp GetServiceCapabilitiesResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil {
return nil, fmt.Errorf("GetDeviceIOServiceCapabilities failed: %w", err)
}
return &DeviceIOServiceCapabilities{
VideoSources: resp.Capabilities.VideoSources,
VideoOutputs: resp.Capabilities.VideoOutputs,
AudioSources: resp.Capabilities.AudioSources,
AudioOutputs: resp.Capabilities.AudioOutputs,
RelayOutputs: resp.Capabilities.RelayOutputs,
SerialPorts: resp.Capabilities.SerialPorts,
DigitalInputs: resp.Capabilities.DigitalInputs,
DigitalInputOptions: resp.Capabilities.DigitalInputOptions,
SerialPortConfiguration: resp.Capabilities.SerialPortConfiguration,
}, nil
}
// GetDigitalInputs retrieves all digital inputs.
func (c *Client) GetDigitalInputs(ctx context.Context) ([]*DigitalInput, error) {
endpoint := c.getDeviceIOEndpoint()
type GetDigitalInputs struct {
XMLName xml.Name `xml:"tmd:GetDigitalInputs"`
Xmlns string `xml:"xmlns:tmd,attr"`
}
type GetDigitalInputsResponse struct {
XMLName xml.Name `xml:"GetDigitalInputsResponse"`
DigitalInputs []struct {
Token string `xml:"token,attr"`
IdleState string `xml:"IdleState,attr"`
} `xml:"DigitalInputs"`
}
req := GetDigitalInputs{
Xmlns: deviceIONamespace,
}
var resp GetDigitalInputsResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil {
return nil, fmt.Errorf("GetDigitalInputs failed: %w", err)
}
inputs := make([]*DigitalInput, len(resp.DigitalInputs))
for i, di := range resp.DigitalInputs {
inputs[i] = &DigitalInput{
Token: di.Token,
IdleState: DigitalIdleState(di.IdleState),
}
}
return inputs, nil
}
// GetDigitalInputConfigurationOptions retrieves digital input configuration options.
func (c *Client) GetDigitalInputConfigurationOptions(ctx context.Context, token string) (*DigitalInputConfigurationOptions, error) {
if token == "" {
return nil, ErrInvalidDigitalInputToken
}
endpoint := c.getDeviceIOEndpoint()
type GetDigitalInputConfigurationOptions struct {
XMLName xml.Name `xml:"tmd:GetDigitalInputConfigurationOptions"`
Xmlns string `xml:"xmlns:tmd,attr"`
Token string `xml:"tmd:Token"`
}
type GetDigitalInputConfigurationOptionsResponse struct {
XMLName xml.Name `xml:"GetDigitalInputConfigurationOptionsResponse"`
DigitalInputConfigurationOptions struct {
IdleState []string `xml:"IdleState"`
} `xml:"DigitalInputConfigurationOptions"`
}
req := GetDigitalInputConfigurationOptions{
Xmlns: deviceIONamespace,
Token: token,
}
var resp GetDigitalInputConfigurationOptionsResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil {
return nil, fmt.Errorf("GetDigitalInputConfigurationOptions failed: %w", err)
}
options := &DigitalInputConfigurationOptions{
IdleStateOptions: make([]DigitalIdleState, len(resp.DigitalInputConfigurationOptions.IdleState)),
}
for i, state := range resp.DigitalInputConfigurationOptions.IdleState {
options.IdleStateOptions[i] = DigitalIdleState(state)
}
return options, nil
}
// SetDigitalInputConfigurations sets digital input configurations.
func (c *Client) SetDigitalInputConfigurations(ctx context.Context, inputs []*DigitalInput) error {
if len(inputs) == 0 {
return ErrDigitalInputConfigNil
}
endpoint := c.getDeviceIOEndpoint()
type DigitalInputXML struct {
Token string `xml:"token,attr"`
IdleState string `xml:"IdleState,attr,omitempty"`
}
type SetDigitalInputConfigurations struct {
XMLName xml.Name `xml:"tmd:SetDigitalInputConfigurations"`
Xmlns string `xml:"xmlns:tmd,attr"`
DigitalInputs []DigitalInputXML `xml:"tmd:DigitalInputs"`
}
type SetDigitalInputConfigurationsResponse struct {
XMLName xml.Name `xml:"SetDigitalInputConfigurationsResponse"`
}
digitalInputsXML := make([]DigitalInputXML, len(inputs))
for i, input := range inputs {
if input.Token == "" {
return ErrInvalidDigitalInputToken
}
digitalInputsXML[i] = DigitalInputXML{
Token: input.Token,
IdleState: string(input.IdleState),
}
}
req := SetDigitalInputConfigurations{
Xmlns: deviceIONamespace,
DigitalInputs: digitalInputsXML,
}
var resp SetDigitalInputConfigurationsResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil {
return fmt.Errorf("SetDigitalInputConfigurations failed: %w", err)
}
return nil
}
// GetVideoOutputs retrieves all video outputs.
func (c *Client) GetVideoOutputs(ctx context.Context) ([]*VideoOutput, error) {
endpoint := c.getDeviceIOEndpoint()
type GetVideoOutputs struct {
XMLName xml.Name `xml:"tmd:GetVideoOutputs"`
Xmlns string `xml:"xmlns:tmd,attr"`
}
type GetVideoOutputsResponse struct {
XMLName xml.Name `xml:"GetVideoOutputsResponse"`
VideoOutputs []struct {
Token string `xml:"token,attr"`
Layout *struct {
Pane []struct {
Pane string `xml:"Pane,attr"`
Area struct {
Bottom float64 `xml:"bottom,attr"`
Top float64 `xml:"top,attr"`
Right float64 `xml:"right,attr"`
Left float64 `xml:"left,attr"`
} `xml:"Area"`
} `xml:"Pane"`
} `xml:"Layout"`
Resolution *struct {
Width int `xml:"Width"`
Height int `xml:"Height"`
} `xml:"Resolution"`
RefreshRate float64 `xml:"RefreshRate"`
AspectRatio string `xml:"AspectRatio"`
} `xml:"VideoOutputs"`
}
req := GetVideoOutputs{
Xmlns: deviceIONamespace,
}
var resp GetVideoOutputsResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil {
return nil, fmt.Errorf("GetVideoOutputs failed: %w", err)
}
outputs := make([]*VideoOutput, len(resp.VideoOutputs))
for i, vo := range resp.VideoOutputs {
output := &VideoOutput{
Token: vo.Token,
RefreshRate: vo.RefreshRate,
AspectRatio: vo.AspectRatio,
}
if vo.Resolution != nil {
output.Resolution = &VideoResolution{
Width: vo.Resolution.Width,
Height: vo.Resolution.Height,
}
}
if vo.Layout != nil {
output.Layout = &Layout{
Pane: make([]PaneLayout, len(vo.Layout.Pane)),
}
for j, pane := range vo.Layout.Pane {
output.Layout.Pane[j] = PaneLayout{
Pane: pane.Pane,
Area: FloatRectangle{
Bottom: pane.Area.Bottom,
Top: pane.Area.Top,
Right: pane.Area.Right,
Left: pane.Area.Left,
},
}
}
}
outputs[i] = output
}
return outputs, nil
}
// GetSerialPorts retrieves all serial ports.
func (c *Client) GetSerialPorts(ctx context.Context) ([]*SerialPort, error) {
endpoint := c.getDeviceIOEndpoint()
type GetSerialPorts struct {
XMLName xml.Name `xml:"tmd:GetSerialPorts"`
Xmlns string `xml:"xmlns:tmd,attr"`
}
type GetSerialPortsResponse struct {
XMLName xml.Name `xml:"GetSerialPortsResponse"`
SerialPorts []struct {
Token string `xml:"token,attr"`
Type string `xml:"Type"`
} `xml:"SerialPorts"`
}
req := GetSerialPorts{
Xmlns: deviceIONamespace,
}
var resp GetSerialPortsResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil {
return nil, fmt.Errorf("GetSerialPorts failed: %w", err)
}
ports := make([]*SerialPort, len(resp.SerialPorts))
for i, sp := range resp.SerialPorts {
ports[i] = &SerialPort{
Token: sp.Token,
Type: SerialPortType(sp.Type),
}
}
return ports, nil
}
// GetSerialPortConfiguration retrieves a serial port configuration.
func (c *Client) GetSerialPortConfiguration(ctx context.Context, serialPortToken string) (*SerialPortConfiguration, error) {
if serialPortToken == "" {
return nil, ErrInvalidSerialPortToken
}
endpoint := c.getDeviceIOEndpoint()
type GetSerialPortConfiguration struct {
XMLName xml.Name `xml:"tmd:GetSerialPortConfiguration"`
Xmlns string `xml:"xmlns:tmd,attr"`
SerialPortToken string `xml:"tmd:SerialPortToken"`
}
type GetSerialPortConfigurationResponse struct {
XMLName xml.Name `xml:"GetSerialPortConfigurationResponse"`
SerialPortConfiguration struct {
Token string `xml:"token,attr"`
Type string `xml:"Type"`
BaudRate int `xml:"BaudRate"`
ParityBit string `xml:"ParityBit"`
CharacterLength int `xml:"CharacterLength"`
StopBit float64 `xml:"StopBit"`
} `xml:"SerialPortConfiguration"`
}
req := GetSerialPortConfiguration{
Xmlns: deviceIONamespace,
SerialPortToken: serialPortToken,
}
var resp GetSerialPortConfigurationResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil {
return nil, fmt.Errorf("GetSerialPortConfiguration failed: %w", err)
}
return &SerialPortConfiguration{
Token: resp.SerialPortConfiguration.Token,
Type: SerialPortType(resp.SerialPortConfiguration.Type),
BaudRate: resp.SerialPortConfiguration.BaudRate,
ParityBit: ParityBit(resp.SerialPortConfiguration.ParityBit),
CharacterLength: resp.SerialPortConfiguration.CharacterLength,
StopBit: resp.SerialPortConfiguration.StopBit,
}, nil
}
// GetSerialPortConfigurationOptions retrieves serial port configuration options.
func (c *Client) GetSerialPortConfigurationOptions(ctx context.Context, serialPortToken string) (*SerialPortConfigurationOptions, error) {
if serialPortToken == "" {
return nil, ErrInvalidSerialPortToken
}
endpoint := c.getDeviceIOEndpoint()
type GetSerialPortConfigurationOptions struct {
XMLName xml.Name `xml:"tmd:GetSerialPortConfigurationOptions"`
Xmlns string `xml:"xmlns:tmd,attr"`
SerialPortToken string `xml:"tmd:SerialPortToken"`
}
type GetSerialPortConfigurationOptionsResponse struct {
XMLName xml.Name `xml:"GetSerialPortConfigurationOptionsResponse"`
SerialPortConfigurationOptions struct {
Token string `xml:"token,attr"`
BaudRateList []int `xml:"BaudRateList>Items"`
ParityBitList []string `xml:"ParityBitList>Items"`
CharLengthList []int `xml:"CharacterLengthList>Items"`
StopBitList []float64 `xml:"StopBitList>Items"`
} `xml:"SerialPortConfigurationOptions"`
}
req := GetSerialPortConfigurationOptions{
Xmlns: deviceIONamespace,
SerialPortToken: serialPortToken,
}
var resp GetSerialPortConfigurationOptionsResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil {
return nil, fmt.Errorf("GetSerialPortConfigurationOptions failed: %w", err)
}
options := &SerialPortConfigurationOptions{
Token: resp.SerialPortConfigurationOptions.Token,
BaudRateList: resp.SerialPortConfigurationOptions.BaudRateList,
CharacterLengthList: resp.SerialPortConfigurationOptions.CharLengthList,
StopBitList: resp.SerialPortConfigurationOptions.StopBitList,
}
// Convert parity bit strings to ParityBit type.
options.ParityBitList = make([]ParityBit, len(resp.SerialPortConfigurationOptions.ParityBitList))
for i, pb := range resp.SerialPortConfigurationOptions.ParityBitList {
options.ParityBitList[i] = ParityBit(pb)
}
return options, nil
}
// SetSerialPortConfiguration sets a serial port configuration.
func (c *Client) SetSerialPortConfiguration(ctx context.Context, config *SerialPortConfiguration) error {
if config == nil {
return ErrSerialPortConfigNil
}
if config.Token == "" {
return ErrInvalidSerialPortToken
}
endpoint := c.getDeviceIOEndpoint()
type SerialPortConfigurationXML struct {
Token string `xml:"token,attr"`
Type string `xml:"tmd:Type"`
BaudRate int `xml:"tmd:BaudRate"`
ParityBit string `xml:"tmd:ParityBit"`
CharacterLength int `xml:"tmd:CharacterLength"`
StopBit float64 `xml:"tmd:StopBit"`
}
type SetSerialPortConfiguration struct {
XMLName xml.Name `xml:"tmd:SetSerialPortConfiguration"`
Xmlns string `xml:"xmlns:tmd,attr"`
SerialPortConfiguration SerialPortConfigurationXML `xml:"tmd:SerialPortConfiguration"`
}
type SetSerialPortConfigurationResponse struct {
XMLName xml.Name `xml:"SetSerialPortConfigurationResponse"`
}
req := SetSerialPortConfiguration{
Xmlns: deviceIONamespace,
SerialPortConfiguration: SerialPortConfigurationXML{
Token: config.Token,
Type: string(config.Type),
BaudRate: config.BaudRate,
ParityBit: string(config.ParityBit),
CharacterLength: config.CharacterLength,
StopBit: config.StopBit,
},
}
var resp SetSerialPortConfigurationResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil {
return fmt.Errorf("SetSerialPortConfiguration failed: %w", err)
}
return nil
}
// SendReceiveSerialCommand sends a serial command and receives a response.
func (c *Client) SendReceiveSerialCommand(ctx context.Context, serialPortToken string, data []byte, timeoutSeconds, dataLength int) ([]byte, error) {
if serialPortToken == "" {
return nil, ErrInvalidSerialPortToken
}
if len(data) == 0 {
return nil, ErrInvalidSerialData
}
endpoint := c.getDeviceIOEndpoint()
type SerialData struct {
Binary string `xml:"tt:Binary,omitempty"`
}
type SendReceiveSerialCommand struct {
XMLName xml.Name `xml:"tmd:SendReceiveSerialCommand"`
Xmlns string `xml:"xmlns:tmd,attr"`
XmlnsTT string `xml:"xmlns:tt,attr"`
Token string `xml:"tmd:Token"`
SerialData SerialData `xml:"tmd:SerialData"`
TimeOut string `xml:"tmd:TimeOut,omitempty"`
DataLength int `xml:"tmd:DataLength,omitempty"`
}
type SendReceiveSerialCommandResponse struct {
XMLName xml.Name `xml:"SendReceiveSerialCommandResponse"`
SerialData struct {
Binary string `xml:"Binary"`
} `xml:"SerialData"`
}
req := SendReceiveSerialCommand{
Xmlns: deviceIONamespace,
XmlnsTT: "http://www.onvif.org/ver10/schema",
Token: serialPortToken,
SerialData: SerialData{
Binary: string(data),
},
DataLength: dataLength,
}
if timeoutSeconds > 0 {
req.TimeOut = fmt.Sprintf("PT%dS", timeoutSeconds)
}
var resp SendReceiveSerialCommandResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil {
return nil, fmt.Errorf("SendReceiveSerialCommand failed: %w", err)
}
return []byte(resp.SerialData.Binary), nil
}
// GetVideoOutputConfiguration retrieves a video output configuration.
func (c *Client) GetVideoOutputConfiguration(ctx context.Context, videoOutputToken string) (*VideoOutputConfiguration, error) {
if videoOutputToken == "" {
return nil, ErrInvalidVideoOutputToken
}
endpoint := c.getDeviceIOEndpoint()
type GetVideoOutputConfiguration struct {
XMLName xml.Name `xml:"tmd:GetVideoOutputConfiguration"`
Xmlns string `xml:"xmlns:tmd,attr"`
VideoOutputToken string `xml:"tmd:VideoOutputToken"`
}
type GetVideoOutputConfigurationResponse struct {
XMLName xml.Name `xml:"GetVideoOutputConfigurationResponse"`
VideoOutputConfiguration struct {
Token string `xml:"token,attr"`
Name string `xml:"Name"`
UseCount int `xml:"UseCount"`
OutputToken string `xml:"OutputToken"`
} `xml:"VideoOutputConfiguration"`
}
req := GetVideoOutputConfiguration{
Xmlns: deviceIONamespace,
VideoOutputToken: videoOutputToken,
}
var resp GetVideoOutputConfigurationResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil {
return nil, fmt.Errorf("GetVideoOutputConfiguration failed: %w", err)
}
return &VideoOutputConfiguration{
Token: resp.VideoOutputConfiguration.Token,
Name: resp.VideoOutputConfiguration.Name,
UseCount: resp.VideoOutputConfiguration.UseCount,
OutputToken: resp.VideoOutputConfiguration.OutputToken,
}, nil
}
// GetVideoOutputConfigurationOptions retrieves video output configuration options.
func (c *Client) GetVideoOutputConfigurationOptions(ctx context.Context, videoOutputToken string) (*VideoOutputConfigurationOptions, error) {
if videoOutputToken == "" {
return nil, ErrInvalidVideoOutputToken
}
endpoint := c.getDeviceIOEndpoint()
type GetVideoOutputConfigurationOptions struct {
XMLName xml.Name `xml:"tmd:GetVideoOutputConfigurationOptions"`
Xmlns string `xml:"xmlns:tmd,attr"`
VideoOutputToken string `xml:"tmd:VideoOutputToken"`
}
type GetVideoOutputConfigurationOptionsResponse struct {
XMLName xml.Name `xml:"GetVideoOutputConfigurationOptionsResponse"`
VideoOutputConfigurationOptions struct {
Name struct {
Min int `xml:"Min,attr"`
Max int `xml:"Max,attr"`
} `xml:"Name"`
OutputTokensAvailable []string `xml:"OutputTokensAvailable"`
} `xml:"VideoOutputConfigurationOptions"`
}
req := GetVideoOutputConfigurationOptions{
Xmlns: deviceIONamespace,
VideoOutputToken: videoOutputToken,
}
var resp GetVideoOutputConfigurationOptionsResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil {
return nil, fmt.Errorf("GetVideoOutputConfigurationOptions failed: %w", err)
}
return &VideoOutputConfigurationOptions{
Name: StringRange{
Min: resp.VideoOutputConfigurationOptions.Name.Min,
Max: resp.VideoOutputConfigurationOptions.Name.Max,
},
OutputTokensAvailable: resp.VideoOutputConfigurationOptions.OutputTokensAvailable,
}, nil
}
// SetVideoOutputConfiguration sets a video output configuration.
func (c *Client) SetVideoOutputConfiguration(ctx context.Context, config *VideoOutputConfiguration) error {
if config == nil {
return ErrVideoOutputConfigNil
}
if config.Token == "" {
return ErrInvalidVideoOutputToken
}
endpoint := c.getDeviceIOEndpoint()
type VideoOutputConfigurationXML struct {
Token string `xml:"token,attr"`
Name string `xml:"tt:Name"`
UseCount int `xml:"tt:UseCount"`
OutputToken string `xml:"tt:OutputToken"`
}
type SetVideoOutputConfiguration struct {
XMLName xml.Name `xml:"tmd:SetVideoOutputConfiguration"`
Xmlns string `xml:"xmlns:tmd,attr"`
XmlnsTT string `xml:"xmlns:tt,attr"`
Configuration VideoOutputConfigurationXML `xml:"tmd:Configuration"`
ForcePersistence bool `xml:"tmd:ForcePersistence"`
}
type SetVideoOutputConfigurationResponse struct {
XMLName xml.Name `xml:"SetVideoOutputConfigurationResponse"`
}
req := SetVideoOutputConfiguration{
Xmlns: deviceIONamespace,
XmlnsTT: "http://www.onvif.org/ver10/schema",
Configuration: VideoOutputConfigurationXML{
Token: config.Token,
Name: config.Name,
UseCount: config.UseCount,
OutputToken: config.OutputToken,
},
ForcePersistence: config.ForcePersistence,
}
var resp SetVideoOutputConfigurationResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil {
return fmt.Errorf("SetVideoOutputConfiguration failed: %w", err)
}
return nil
}
// GetRelayOutputOptions retrieves relay output options.
func (c *Client) GetRelayOutputOptions(ctx context.Context, relayOutputToken string) (*RelayOutputOptions, error) {
if relayOutputToken == "" {
return nil, ErrInvalidRelayOutputToken
}
endpoint := c.getDeviceIOEndpoint()
type GetRelayOutputOptions struct {
XMLName xml.Name `xml:"tmd:GetRelayOutputOptions"`
Xmlns string `xml:"xmlns:tmd,attr"`
RelayOutputToken string `xml:"tmd:RelayOutputToken"`
}
type GetRelayOutputOptionsResponse struct {
XMLName xml.Name `xml:"GetRelayOutputOptionsResponse"`
RelayOutputOptions struct {
Token string `xml:"token,attr"`
Mode []string `xml:"Mode"`
DelayTimes []string `xml:"DelayTimes"`
Discrete bool `xml:"Discrete"`
} `xml:"RelayOutputOptions"`
}
req := GetRelayOutputOptions{
Xmlns: deviceIONamespace,
RelayOutputToken: relayOutputToken,
}
var resp GetRelayOutputOptionsResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil {
return nil, fmt.Errorf("GetRelayOutputOptions failed: %w", err)
}
modes := make([]RelayMode, len(resp.RelayOutputOptions.Mode))
for i, m := range resp.RelayOutputOptions.Mode {
modes[i] = RelayMode(m)
}
return &RelayOutputOptions{
Token: resp.RelayOutputOptions.Token,
Mode: modes,
DelayTimes: resp.RelayOutputOptions.DelayTimes,
Discrete: resp.RelayOutputOptions.Discrete,
}, nil
}
+922
View File
@@ -0,0 +1,922 @@
package onvif
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
const testDeviceIOXMLHeader = `<?xml version="1.0" encoding="UTF-8"?>`
func newMockDeviceIOServer() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/soap+xml")
body := make([]byte, r.ContentLength)
_, _ = r.Body.Read(body)
bodyStr := string(body)
var response string
switch {
case strings.Contains(bodyStr, "GetServiceCapabilities") && strings.Contains(bodyStr, "deviceIO"):
response = testDeviceIOXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<tmd:GetServiceCapabilitiesResponse xmlns:tmd="http://www.onvif.org/ver10/deviceIO/wsdl">
<tmd:Capabilities
VideoSources="4"
VideoOutputs="2"
AudioSources="2"
AudioOutputs="2"
RelayOutputs="4"
SerialPorts="2"
DigitalInputs="8"
DigitalInputOptions="true"
SerialPortConfiguration="true"/>
</tmd:GetServiceCapabilitiesResponse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
case strings.Contains(bodyStr, "GetDigitalInputConfigurationOptions"):
response = testDeviceIOXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<tmd:GetDigitalInputConfigurationOptionsResponse xmlns:tmd="http://www.onvif.org/ver10/deviceIO/wsdl">
<tmd:DigitalInputConfigurationOptions>
<tmd:IdleState>open</tmd:IdleState>
<tmd:IdleState>closed</tmd:IdleState>
</tmd:DigitalInputConfigurationOptions>
</tmd:GetDigitalInputConfigurationOptionsResponse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
case strings.Contains(bodyStr, "GetDigitalInputs"):
response = testDeviceIOXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<tmd:GetDigitalInputsResponse xmlns:tmd="http://www.onvif.org/ver10/deviceIO/wsdl">
<tmd:DigitalInputs token="input_001" IdleState="open"/>
<tmd:DigitalInputs token="input_002" IdleState="closed"/>
</tmd:GetDigitalInputsResponse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
case strings.Contains(bodyStr, "SetDigitalInputConfigurations"):
response = testDeviceIOXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<tmd:SetDigitalInputConfigurationsResponse xmlns:tmd="http://www.onvif.org/ver10/deviceIO/wsdl"/>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
case strings.Contains(bodyStr, "GetVideoOutputs"):
response = testDeviceIOXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<tmd:GetVideoOutputsResponse xmlns:tmd="http://www.onvif.org/ver10/deviceIO/wsdl">
<tmd:VideoOutputs token="video_out_001">
<tmd:Layout>
<tt:Pane xmlns:tt="http://www.onvif.org/ver10/schema" Pane="main">
<tt:Area bottom="1.0" top="0.0" right="1.0" left="0.0"/>
</tt:Pane>
</tmd:Layout>
<tmd:Resolution>
<tmd:Width>1920</tmd:Width>
<tmd:Height>1080</tmd:Height>
</tmd:Resolution>
<tmd:RefreshRate>60.0</tmd:RefreshRate>
<tmd:AspectRatio>16:9</tmd:AspectRatio>
</tmd:VideoOutputs>
</tmd:GetVideoOutputsResponse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
case strings.Contains(bodyStr, "GetSerialPortConfigurationOptions"):
response = testDeviceIOXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<tmd:GetSerialPortConfigurationOptionsResponse xmlns:tmd="http://www.onvif.org/ver10/deviceIO/wsdl">
<tmd:SerialPortConfigurationOptions token="serial_001">
<tmd:BaudRateList><tmd:Items>9600</tmd:Items><tmd:Items>19200</tmd:Items><tmd:Items>38400</tmd:Items></tmd:BaudRateList>
<tmd:ParityBitList><tmd:Items>None</tmd:Items><tmd:Items>Odd</tmd:Items><tmd:Items>Even</tmd:Items></tmd:ParityBitList>
<tmd:CharacterLengthList><tmd:Items>7</tmd:Items><tmd:Items>8</tmd:Items></tmd:CharacterLengthList>
<tmd:StopBitList><tmd:Items>1</tmd:Items><tmd:Items>2</tmd:Items></tmd:StopBitList>
</tmd:SerialPortConfigurationOptions>
</tmd:GetSerialPortConfigurationOptionsResponse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
case strings.Contains(bodyStr, "GetSerialPortConfiguration"):
response = testDeviceIOXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<tmd:GetSerialPortConfigurationResponse xmlns:tmd="http://www.onvif.org/ver10/deviceIO/wsdl">
<tmd:SerialPortConfiguration token="serial_001">
<tmd:Type>RS232</tmd:Type>
<tmd:BaudRate>9600</tmd:BaudRate>
<tmd:ParityBit>None</tmd:ParityBit>
<tmd:CharacterLength>8</tmd:CharacterLength>
<tmd:StopBit>1</tmd:StopBit>
</tmd:SerialPortConfiguration>
</tmd:GetSerialPortConfigurationResponse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
case strings.Contains(bodyStr, "GetSerialPorts"):
response = testDeviceIOXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<tmd:GetSerialPortsResponse xmlns:tmd="http://www.onvif.org/ver10/deviceIO/wsdl">
<tmd:SerialPorts token="serial_001">
<tmd:Type>RS232</tmd:Type>
</tmd:SerialPorts>
<tmd:SerialPorts token="serial_002">
<tmd:Type>RS485</tmd:Type>
</tmd:SerialPorts>
</tmd:GetSerialPortsResponse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
case strings.Contains(bodyStr, "SetSerialPortConfiguration"):
response = testDeviceIOXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<tmd:SetSerialPortConfigurationResponse xmlns:tmd="http://www.onvif.org/ver10/deviceIO/wsdl"/>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
case strings.Contains(bodyStr, "SendReceiveSerialCommand"):
response = testDeviceIOXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<tmd:SendReceiveSerialCommandResponse xmlns:tmd="http://www.onvif.org/ver10/deviceIO/wsdl">
<tmd:SerialData>
<tt:Binary xmlns:tt="http://www.onvif.org/ver10/schema">OK</tt:Binary>
</tmd:SerialData>
</tmd:SendReceiveSerialCommandResponse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
case strings.Contains(bodyStr, "GetVideoOutputConfigurationOptions"):
response = testDeviceIOXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<tmd:GetVideoOutputConfigurationOptionsResponse xmlns:tmd="http://www.onvif.org/ver10/deviceIO/wsdl">
<tmd:VideoOutputConfigurationOptions>
<tmd:Name Min="1" Max="64"/>
<tmd:OutputTokensAvailable>video_out_001</tmd:OutputTokensAvailable>
<tmd:OutputTokensAvailable>video_out_002</tmd:OutputTokensAvailable>
</tmd:VideoOutputConfigurationOptions>
</tmd:GetVideoOutputConfigurationOptionsResponse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
case strings.Contains(bodyStr, "GetVideoOutputConfiguration"):
response = testDeviceIOXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<tmd:GetVideoOutputConfigurationResponse xmlns:tmd="http://www.onvif.org/ver10/deviceIO/wsdl">
<tmd:VideoOutputConfiguration token="config_001">
<tmd:Name>Main Output</tmd:Name>
<tmd:UseCount>2</tmd:UseCount>
<tmd:OutputToken>video_out_001</tmd:OutputToken>
</tmd:VideoOutputConfiguration>
</tmd:GetVideoOutputConfigurationResponse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
case strings.Contains(bodyStr, "SetVideoOutputConfiguration"):
response = testDeviceIOXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<tmd:SetVideoOutputConfigurationResponse xmlns:tmd="http://www.onvif.org/ver10/deviceIO/wsdl"/>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
case strings.Contains(bodyStr, "GetRelayOutputOptions"):
response = testDeviceIOXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<tmd:GetRelayOutputOptionsResponse xmlns:tmd="http://www.onvif.org/ver10/deviceIO/wsdl">
<tmd:RelayOutputOptions token="relay_001">
<tmd:Mode>Monostable</tmd:Mode>
<tmd:Mode>Bistable</tmd:Mode>
<tmd:DelayTimes>PT1S</tmd:DelayTimes>
<tmd:DelayTimes>PT5S</tmd:DelayTimes>
<tmd:DelayTimes>PT10S</tmd:DelayTimes>
<tmd:Discrete>true</tmd:Discrete>
</tmd:RelayOutputOptions>
</tmd:GetRelayOutputOptionsResponse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
default:
response = testDeviceIOXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<SOAP-ENV:Fault>
<SOAP-ENV:Code><SOAP-ENV:Value>SOAP-ENV:Receiver</SOAP-ENV:Value></SOAP-ENV:Code>
<SOAP-ENV:Reason><SOAP-ENV:Text>Unknown action</SOAP-ENV:Text></SOAP-ENV:Reason>
</SOAP-ENV:Fault>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
}
_, _ = w.Write([]byte(response))
}))
}
func TestGetDeviceIOServiceCapabilities(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
caps, err := client.GetDeviceIOServiceCapabilities(ctx)
if err != nil {
t.Fatalf("GetDeviceIOServiceCapabilities failed: %v", err)
}
if caps.VideoSources != 4 {
t.Errorf("Expected VideoSources to be 4, got %d", caps.VideoSources)
}
if caps.VideoOutputs != 2 {
t.Errorf("Expected VideoOutputs to be 2, got %d", caps.VideoOutputs)
}
if caps.AudioSources != 2 {
t.Errorf("Expected AudioSources to be 2, got %d", caps.AudioSources)
}
if caps.AudioOutputs != 2 {
t.Errorf("Expected AudioOutputs to be 2, got %d", caps.AudioOutputs)
}
if caps.RelayOutputs != 4 {
t.Errorf("Expected RelayOutputs to be 4, got %d", caps.RelayOutputs)
}
if caps.SerialPorts != 2 {
t.Errorf("Expected SerialPorts to be 2, got %d", caps.SerialPorts)
}
if caps.DigitalInputs != 8 {
t.Errorf("Expected DigitalInputs to be 8, got %d", caps.DigitalInputs)
}
if !caps.DigitalInputOptions {
t.Error("Expected DigitalInputOptions to be true")
}
if !caps.SerialPortConfiguration {
t.Error("Expected SerialPortConfiguration to be true")
}
}
func TestGetDigitalInputs(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
inputs, err := client.GetDigitalInputs(ctx)
if err != nil {
t.Fatalf("GetDigitalInputs failed: %v", err)
}
if len(inputs) != 2 {
t.Fatalf("Expected 2 digital inputs, got %d", len(inputs))
}
if inputs[0].Token != "input_001" {
t.Errorf("Expected first input token 'input_001', got '%s'", inputs[0].Token)
}
if inputs[0].IdleState != DigitalIdleOpen {
t.Errorf("Expected first input idle state 'open', got '%s'", inputs[0].IdleState)
}
if inputs[1].IdleState != DigitalIdleClosed {
t.Errorf("Expected second input idle state 'closed', got '%s'", inputs[1].IdleState)
}
}
func TestGetDigitalInputConfigurationOptions(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
options, err := client.GetDigitalInputConfigurationOptions(ctx, "input_001")
if err != nil {
t.Fatalf("GetDigitalInputConfigurationOptions failed: %v", err)
}
if len(options.IdleStateOptions) != 2 {
t.Errorf("Expected 2 idle state options, got %d", len(options.IdleStateOptions))
}
}
func TestGetDigitalInputConfigurationOptionsInvalidToken(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
_, err = client.GetDigitalInputConfigurationOptions(ctx, "")
if !errors.Is(err, ErrInvalidDigitalInputToken) {
t.Errorf("Expected ErrInvalidDigitalInputToken, got %v", err)
}
}
func TestSetDigitalInputConfigurations(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
inputs := []*DigitalInput{
{Token: "input_001", IdleState: DigitalIdleOpen},
{Token: "input_002", IdleState: DigitalIdleClosed},
}
err = client.SetDigitalInputConfigurations(ctx, inputs)
if err != nil {
t.Fatalf("SetDigitalInputConfigurations failed: %v", err)
}
}
func TestSetDigitalInputConfigurationsValidation(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
// Test empty inputs.
err = client.SetDigitalInputConfigurations(ctx, []*DigitalInput{})
if !errors.Is(err, ErrDigitalInputConfigNil) {
t.Errorf("Expected ErrDigitalInputConfigNil, got %v", err)
}
// Test input with empty token.
inputs := []*DigitalInput{{Token: "", IdleState: DigitalIdleOpen}}
err = client.SetDigitalInputConfigurations(ctx, inputs)
if !errors.Is(err, ErrInvalidDigitalInputToken) {
t.Errorf("Expected ErrInvalidDigitalInputToken, got %v", err)
}
}
func TestGetVideoOutputs(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
outputs, err := client.GetVideoOutputs(ctx)
if err != nil {
t.Fatalf("GetVideoOutputs failed: %v", err)
}
if len(outputs) != 1 {
t.Fatalf("Expected 1 video output, got %d", len(outputs))
}
if outputs[0].Token != "video_out_001" {
t.Errorf("Expected video output token 'video_out_001', got '%s'", outputs[0].Token)
}
if outputs[0].Resolution == nil {
t.Fatal("Expected Resolution to be set")
}
if outputs[0].Resolution.Width != 1920 {
t.Errorf("Expected resolution width 1920, got %d", outputs[0].Resolution.Width)
}
if outputs[0].Resolution.Height != 1080 {
t.Errorf("Expected resolution height 1080, got %d", outputs[0].Resolution.Height)
}
if outputs[0].RefreshRate != 60.0 {
t.Errorf("Expected refresh rate 60.0, got %f", outputs[0].RefreshRate)
}
if outputs[0].AspectRatio != "16:9" {
t.Errorf("Expected aspect ratio '16:9', got '%s'", outputs[0].AspectRatio)
}
}
func TestGetSerialPorts(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
ports, err := client.GetSerialPorts(ctx)
if err != nil {
t.Fatalf("GetSerialPorts failed: %v", err)
}
if len(ports) != 2 {
t.Fatalf("Expected 2 serial ports, got %d", len(ports))
}
if ports[0].Token != "serial_001" {
t.Errorf("Expected first serial port token 'serial_001', got '%s'", ports[0].Token)
}
if ports[0].Type != SerialPortTypeRS232 {
t.Errorf("Expected first serial port type RS232, got '%s'", ports[0].Type)
}
if ports[1].Type != SerialPortTypeRS485 {
t.Errorf("Expected second serial port type RS485, got '%s'", ports[1].Type)
}
}
func TestGetSerialPortConfiguration(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
config, err := client.GetSerialPortConfiguration(ctx, "serial_001")
if err != nil {
t.Fatalf("GetSerialPortConfiguration failed: %v", err)
}
if config.Token != "serial_001" {
t.Errorf("Expected token 'serial_001', got '%s'", config.Token)
}
if config.Type != SerialPortTypeRS232 {
t.Errorf("Expected type RS232, got '%s'", config.Type)
}
if config.BaudRate != 9600 {
t.Errorf("Expected baud rate 9600, got %d", config.BaudRate)
}
if config.ParityBit != ParityNone {
t.Errorf("Expected parity None, got '%s'", config.ParityBit)
}
if config.CharacterLength != 8 {
t.Errorf("Expected character length 8, got %d", config.CharacterLength)
}
if config.StopBit != 1 {
t.Errorf("Expected stop bit 1, got %f", config.StopBit)
}
}
func TestGetSerialPortConfigurationInvalidToken(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
_, err = client.GetSerialPortConfiguration(ctx, "")
if !errors.Is(err, ErrInvalidSerialPortToken) {
t.Errorf("Expected ErrInvalidSerialPortToken, got %v", err)
}
}
func TestGetSerialPortConfigurationOptions(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
options, err := client.GetSerialPortConfigurationOptions(ctx, "serial_001")
if err != nil {
t.Fatalf("GetSerialPortConfigurationOptions failed: %v", err)
}
if len(options.BaudRateList) != 3 {
t.Errorf("Expected 3 baud rate options, got %d", len(options.BaudRateList))
}
if len(options.ParityBitList) != 3 {
t.Errorf("Expected 3 parity bit options, got %d", len(options.ParityBitList))
}
if len(options.CharacterLengthList) != 2 {
t.Errorf("Expected 2 character length options, got %d", len(options.CharacterLengthList))
}
if len(options.StopBitList) != 2 {
t.Errorf("Expected 2 stop bit options, got %d", len(options.StopBitList))
}
}
func TestGetSerialPortConfigurationOptionsInvalidToken(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
_, err = client.GetSerialPortConfigurationOptions(ctx, "")
if !errors.Is(err, ErrInvalidSerialPortToken) {
t.Errorf("Expected ErrInvalidSerialPortToken, got %v", err)
}
}
func TestSetSerialPortConfiguration(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
config := &SerialPortConfiguration{
Token: "serial_001",
Type: SerialPortTypeRS232,
BaudRate: 19200,
ParityBit: ParityNone,
CharacterLength: 8,
StopBit: 1,
}
err = client.SetSerialPortConfiguration(ctx, config)
if err != nil {
t.Fatalf("SetSerialPortConfiguration failed: %v", err)
}
}
func TestSetSerialPortConfigurationValidation(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
// Test nil config.
err = client.SetSerialPortConfiguration(ctx, nil)
if !errors.Is(err, ErrSerialPortConfigNil) {
t.Errorf("Expected ErrSerialPortConfigNil, got %v", err)
}
// Test empty token.
config := &SerialPortConfiguration{Token: ""}
err = client.SetSerialPortConfiguration(ctx, config)
if !errors.Is(err, ErrInvalidSerialPortToken) {
t.Errorf("Expected ErrInvalidSerialPortToken, got %v", err)
}
}
func TestSendReceiveSerialCommand(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
response, err := client.SendReceiveSerialCommand(ctx, "serial_001", []byte("HELLO"), 5, 10)
if err != nil {
t.Fatalf("SendReceiveSerialCommand failed: %v", err)
}
if string(response) != "OK" {
t.Errorf("Expected response 'OK', got '%s'", string(response))
}
}
func TestSendReceiveSerialCommandValidation(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
// Test empty token.
_, err = client.SendReceiveSerialCommand(ctx, "", []byte("HELLO"), 5, 10)
if !errors.Is(err, ErrInvalidSerialPortToken) {
t.Errorf("Expected ErrInvalidSerialPortToken, got %v", err)
}
// Test empty data.
_, err = client.SendReceiveSerialCommand(ctx, "serial_001", []byte{}, 5, 10)
if !errors.Is(err, ErrInvalidSerialData) {
t.Errorf("Expected ErrInvalidSerialData, got %v", err)
}
}
func TestDigitalIdleStateConstants(t *testing.T) {
if DigitalIdleOpen != "open" {
t.Errorf("DigitalIdleOpen should be 'open'")
}
if DigitalIdleClosed != "closed" {
t.Errorf("DigitalIdleClosed should be 'closed'")
}
}
func TestSerialPortTypeConstants(t *testing.T) {
if SerialPortTypeRS232 != "RS232" {
t.Errorf("SerialPortTypeRS232 should be 'RS232'")
}
if SerialPortTypeRS422 != "RS422" {
t.Errorf("SerialPortTypeRS422 should be 'RS422'")
}
if SerialPortTypeRS485 != "RS485" {
t.Errorf("SerialPortTypeRS485 should be 'RS485'")
}
if SerialPortTypeGeneric != "Generic" {
t.Errorf("SerialPortTypeGeneric should be 'Generic'")
}
}
func TestParityBitConstants(t *testing.T) {
if ParityNone != "None" {
t.Errorf("ParityNone should be 'None'")
}
if ParityOdd != "Odd" {
t.Errorf("ParityOdd should be 'Odd'")
}
if ParityEven != "Even" {
t.Errorf("ParityEven should be 'Even'")
}
if ParityMark != "Mark" {
t.Errorf("ParityMark should be 'Mark'")
}
if ParitySpace != "Space" {
t.Errorf("ParitySpace should be 'Space'")
}
}
func TestGetVideoOutputConfiguration(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
config, err := client.GetVideoOutputConfiguration(ctx, "video_out_001")
if err != nil {
t.Fatalf("GetVideoOutputConfiguration failed: %v", err)
}
if config.Token != "config_001" {
t.Errorf("Expected token 'config_001', got '%s'", config.Token)
}
if config.Name != "Main Output" {
t.Errorf("Expected name 'Main Output', got '%s'", config.Name)
}
if config.UseCount != 2 {
t.Errorf("Expected use count 2, got %d", config.UseCount)
}
if config.OutputToken != "video_out_001" {
t.Errorf("Expected output token 'video_out_001', got '%s'", config.OutputToken)
}
}
func TestGetVideoOutputConfigurationInvalidToken(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
_, err = client.GetVideoOutputConfiguration(ctx, "")
if !errors.Is(err, ErrInvalidVideoOutputToken) {
t.Errorf("Expected ErrInvalidVideoOutputToken, got %v", err)
}
}
func TestGetVideoOutputConfigurationOptions(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
options, err := client.GetVideoOutputConfigurationOptions(ctx, "video_out_001")
if err != nil {
t.Fatalf("GetVideoOutputConfigurationOptions failed: %v", err)
}
if options.Name.Min != 1 {
t.Errorf("Expected Name.Min to be 1, got %d", options.Name.Min)
}
if options.Name.Max != 64 {
t.Errorf("Expected Name.Max to be 64, got %d", options.Name.Max)
}
if len(options.OutputTokensAvailable) != 2 {
t.Errorf("Expected 2 output tokens available, got %d", len(options.OutputTokensAvailable))
}
}
func TestGetVideoOutputConfigurationOptionsInvalidToken(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
_, err = client.GetVideoOutputConfigurationOptions(ctx, "")
if !errors.Is(err, ErrInvalidVideoOutputToken) {
t.Errorf("Expected ErrInvalidVideoOutputToken, got %v", err)
}
}
func TestSetVideoOutputConfiguration(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
config := &VideoOutputConfiguration{
Token: "config_001",
Name: "Main Output",
UseCount: 2,
OutputToken: "video_out_001",
ForcePersistence: true,
}
err = client.SetVideoOutputConfiguration(ctx, config)
if err != nil {
t.Fatalf("SetVideoOutputConfiguration failed: %v", err)
}
}
func TestSetVideoOutputConfigurationValidation(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
// Test nil config.
err = client.SetVideoOutputConfiguration(ctx, nil)
if !errors.Is(err, ErrVideoOutputConfigNil) {
t.Errorf("Expected ErrVideoOutputConfigNil, got %v", err)
}
// Test empty token.
config := &VideoOutputConfiguration{Token: ""}
err = client.SetVideoOutputConfiguration(ctx, config)
if !errors.Is(err, ErrInvalidVideoOutputToken) {
t.Errorf("Expected ErrInvalidVideoOutputToken, got %v", err)
}
}
func TestGetRelayOutputOptions(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
options, err := client.GetRelayOutputOptions(ctx, "relay_001")
if err != nil {
t.Fatalf("GetRelayOutputOptions failed: %v", err)
}
if options.Token != "relay_001" {
t.Errorf("Expected token 'relay_001', got '%s'", options.Token)
}
if len(options.Mode) != 2 {
t.Errorf("Expected 2 modes, got %d", len(options.Mode))
}
if options.Mode[0] != RelayModeMonostable {
t.Errorf("Expected first mode to be Monostable, got '%s'", options.Mode[0])
}
if options.Mode[1] != RelayModeBistable {
t.Errorf("Expected second mode to be Bistable, got '%s'", options.Mode[1])
}
if len(options.DelayTimes) != 3 {
t.Errorf("Expected 3 delay times, got %d", len(options.DelayTimes))
}
if !options.Discrete {
t.Error("Expected Discrete to be true")
}
}
func TestGetRelayOutputOptionsInvalidToken(t *testing.T) {
server := newMockDeviceIOServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
_, err = client.GetRelayOutputOptions(ctx, "")
if !errors.Is(err, ErrInvalidRelayOutputToken) {
t.Errorf("Expected ErrInvalidRelayOutputToken, got %v", err)
}
}
+778
View File
@@ -0,0 +1,778 @@
package onvif
import (
"context"
"encoding/xml"
"errors"
"fmt"
"time"
"github.com/0x524a/onvif-go/internal/soap"
)
// Event service namespace.
const eventNamespace = "http://www.onvif.org/ver10/events/wsdl"
// Event service errors.
var (
// ErrInvalidSubscriptionReference is returned when subscription reference is invalid.
ErrInvalidSubscriptionReference = errors.New("invalid subscription reference")
// ErrInvalidTerminationTime is returned when termination time is invalid.
ErrInvalidTerminationTime = errors.New("invalid termination time")
// ErrInvalidMessageLimit is returned when message limit is invalid.
ErrInvalidMessageLimit = errors.New("invalid message limit: must be positive")
// ErrInvalidTimeout is returned when timeout is invalid.
ErrInvalidTimeout = errors.New("invalid timeout: must be positive")
// ErrInvalidFilter is returned when filter expression is invalid.
ErrInvalidFilter = errors.New("invalid filter expression")
// ErrInvalidEventBrokerAddress is returned when event broker address is empty.
ErrInvalidEventBrokerAddress = errors.New("invalid event broker address: cannot be empty")
// ErrPullPointNotSupported is returned when pull point is not supported.
ErrPullPointNotSupported = errors.New("pull point subscription not supported")
// ErrEventBrokerConfigNil is returned when event broker config is nil.
ErrEventBrokerConfigNil = errors.New("event broker config cannot be nil")
)
// EventServiceCapabilities represents the capabilities of the event service.
type EventServiceCapabilities struct {
WSSubscriptionPolicySupport bool
WSPausableSubscriptionManagerInterfaceSupport bool
MaxNotificationProducers int
MaxPullPoints int
PersistentNotificationStorage bool
EventBrokerProtocols []string
MaxEventBrokers int
MetadataOverMQTT bool
}
// PullPointSubscription represents a pull point subscription.
type PullPointSubscription struct {
SubscriptionReference string
CurrentTime time.Time
TerminationTime time.Time
}
// NotificationMessage represents a notification message from an event.
type NotificationMessage struct {
Topic string
Message EventMessage
ProducerAddress string
SubscriptionID string
}
// EventMessage represents the content of an event message.
type EventMessage struct {
PropertyOperation string
UtcTime time.Time
Source []SimpleItem
Key []SimpleItem
Data []SimpleItem
}
// EventSimpleItem represents a simple name-value pair in an event message.
// Note: Uses SimpleItem from types.go which has the same structure.
// TopicSet represents the set of topics supported by the device.
type TopicSet struct {
Topics []Topic
}
// Topic represents an event topic.
type Topic struct {
Name string
Description string
Children []Topic
}
// EventBrokerConfig represents an event broker configuration.
type EventBrokerConfig struct {
Address string
TopicPrefix string
UserName string
Password string
CertificateID string
PublishFilter string
QoS int
Status string
CertPathValidation bool
MetadataFilter string
}
// EventProperties represents the event properties of the device.
type EventProperties struct {
TopicNamespaceLocation []string
FixedTopicSet bool
TopicSet TopicSet
TopicExpressionDialects []string
MessageContentFilterDialects []string
ProducerPropertiesFilterDialects []string
MessageContentSchemaLocation []string
}
// getEventEndpoint returns the event endpoint, falling back to the default endpoint if not set.
func (c *Client) getEventEndpoint() string {
c.mu.RLock()
defer c.mu.RUnlock()
if c.eventEndpoint != "" {
return c.eventEndpoint
}
return c.endpoint
}
// SetEventEndpoint sets the event service endpoint.
func (c *Client) SetEventEndpoint(endpoint string) {
c.mu.Lock()
defer c.mu.Unlock()
c.eventEndpoint = endpoint
}
// GetEventServiceCapabilities retrieves the capabilities of the event service.
func (c *Client) GetEventServiceCapabilities(ctx context.Context) (*EventServiceCapabilities, error) {
endpoint := c.getEventEndpoint()
type GetServiceCapabilities struct {
XMLName xml.Name `xml:"tev:GetServiceCapabilities"`
Xmlns string `xml:"xmlns:tev,attr"`
}
type GetServiceCapabilitiesResponse struct {
XMLName xml.Name `xml:"GetServiceCapabilitiesResponse"`
Capabilities struct {
WSSubscriptionPolicySupport bool `xml:"WSSubscriptionPolicySupport,attr"`
WSPausableSubscriptionManagerInterfaceSupport bool `xml:"WSPausableSubscriptionManagerInterfaceSupport,attr"`
MaxNotificationProducers int `xml:"MaxNotificationProducers,attr"`
MaxPullPoints int `xml:"MaxPullPoints,attr"`
PersistentNotificationStorage bool `xml:"PersistentNotificationStorage,attr"`
EventBrokerProtocols string `xml:"EventBrokerProtocols,attr"`
MaxEventBrokers int `xml:"MaxEventBrokers,attr"`
MetadataOverMQTT bool `xml:"MetadataOverMQTT,attr"`
} `xml:"Capabilities"`
}
req := GetServiceCapabilities{
Xmlns: eventNamespace,
}
var resp GetServiceCapabilitiesResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil {
return nil, fmt.Errorf("GetEventServiceCapabilities failed: %w", err)
}
caps := &EventServiceCapabilities{
WSSubscriptionPolicySupport: resp.Capabilities.WSSubscriptionPolicySupport,
WSPausableSubscriptionManagerInterfaceSupport: resp.Capabilities.WSPausableSubscriptionManagerInterfaceSupport,
MaxNotificationProducers: resp.Capabilities.MaxNotificationProducers,
MaxPullPoints: resp.Capabilities.MaxPullPoints,
PersistentNotificationStorage: resp.Capabilities.PersistentNotificationStorage,
MaxEventBrokers: resp.Capabilities.MaxEventBrokers,
MetadataOverMQTT: resp.Capabilities.MetadataOverMQTT,
}
// Parse event broker protocols from space-separated string.
if resp.Capabilities.EventBrokerProtocols != "" {
caps.EventBrokerProtocols = splitSpaceSeparated(resp.Capabilities.EventBrokerProtocols)
}
return caps, nil
}
// CreatePullPointSubscription creates a new pull point subscription.
func (c *Client) CreatePullPointSubscription(
ctx context.Context,
filter string,
initialTerminationTime *time.Duration,
subscriptionPolicy string,
) (*PullPointSubscription, error) {
endpoint := c.getEventEndpoint()
type Filter struct {
TopicExpression string `xml:"wsnt:TopicExpression,omitempty"`
}
type CreatePullPointSubscription struct {
XMLName xml.Name `xml:"tev:CreatePullPointSubscription"`
XmlnsTev string `xml:"xmlns:tev,attr"`
XmlnsWsnt string `xml:"xmlns:wsnt,attr"`
Filter *Filter `xml:"tev:Filter,omitempty"`
InitialTerminationTime string `xml:"tev:InitialTerminationTime,omitempty"`
SubscriptionPolicy string `xml:"tev:SubscriptionPolicy,omitempty"`
}
type CreatePullPointSubscriptionResponse struct {
XMLName xml.Name `xml:"CreatePullPointSubscriptionResponse"`
SubscriptionReference struct {
Address string `xml:"Address"`
} `xml:"SubscriptionReference"`
CurrentTime string `xml:"CurrentTime"`
TerminationTime string `xml:"TerminationTime"`
}
req := CreatePullPointSubscription{
XmlnsTev: eventNamespace,
XmlnsWsnt: "http://docs.oasis-open.org/wsn/b-2",
}
if filter != "" {
req.Filter = &Filter{
TopicExpression: filter,
}
}
if initialTerminationTime != nil {
if *initialTerminationTime <= 0 {
return nil, ErrInvalidTerminationTime
}
req.InitialTerminationTime = formatDuration(*initialTerminationTime)
}
if subscriptionPolicy != "" {
req.SubscriptionPolicy = subscriptionPolicy
}
var resp CreatePullPointSubscriptionResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil {
return nil, fmt.Errorf("CreatePullPointSubscription failed: %w", err)
}
subscription := &PullPointSubscription{
SubscriptionReference: resp.SubscriptionReference.Address,
}
if resp.CurrentTime != "" {
if t, err := time.Parse(time.RFC3339, resp.CurrentTime); err == nil {
subscription.CurrentTime = t
}
}
if resp.TerminationTime != "" {
if t, err := time.Parse(time.RFC3339, resp.TerminationTime); err == nil {
subscription.TerminationTime = t
}
}
return subscription, nil
}
// PullMessages pulls notification messages from a pull point subscription.
func (c *Client) PullMessages(
ctx context.Context,
subscriptionReference string,
timeout time.Duration,
messageLimit int,
) ([]NotificationMessage, error) {
if subscriptionReference == "" {
return nil, ErrInvalidSubscriptionReference
}
if timeout <= 0 {
return nil, ErrInvalidTimeout
}
if messageLimit <= 0 {
return nil, ErrInvalidMessageLimit
}
type PullMessages struct {
XMLName xml.Name `xml:"tev:PullMessages"`
Xmlns string `xml:"xmlns:tev,attr"`
Timeout string `xml:"tev:Timeout"`
MessageLimit int `xml:"tev:MessageLimit"`
}
type SimpleItemXML struct {
Name string `xml:"Name,attr"`
Value string `xml:"Value,attr"`
}
type PullMessagesResponse struct {
XMLName xml.Name `xml:"PullMessagesResponse"`
CurrentTime string `xml:"CurrentTime"`
TerminationTime string `xml:"TerminationTime"`
NotificationMessages []struct {
Topic struct {
Value string `xml:",chardata"`
} `xml:"Topic"`
ProducerReference struct {
Address string `xml:"Address"`
} `xml:"ProducerReference"`
Message struct {
PropertyOperation string `xml:"PropertyOperation,attr"`
UtcTime string `xml:"UtcTime,attr"`
Source struct {
SimpleItems []SimpleItemXML `xml:"SimpleItem"`
} `xml:"Source"`
Key struct {
SimpleItems []SimpleItemXML `xml:"SimpleItem"`
} `xml:"Key"`
Data struct {
SimpleItems []SimpleItemXML `xml:"SimpleItem"`
} `xml:"Data"`
} `xml:"Message"`
} `xml:"NotificationMessage"`
}
req := PullMessages{
Xmlns: eventNamespace,
Timeout: formatDuration(timeout),
MessageLimit: messageLimit,
}
var resp PullMessagesResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, subscriptionReference, "", req, &resp); err != nil {
return nil, fmt.Errorf("PullMessages failed: %w", err)
}
messages := make([]NotificationMessage, len(resp.NotificationMessages))
for i := range resp.NotificationMessages {
nm := &resp.NotificationMessages[i]
msg := NotificationMessage{
Topic: nm.Topic.Value,
ProducerAddress: nm.ProducerReference.Address,
}
msg.Message.PropertyOperation = nm.Message.PropertyOperation
if nm.Message.UtcTime != "" {
if t, err := time.Parse(time.RFC3339, nm.Message.UtcTime); err == nil {
msg.Message.UtcTime = t
}
}
// Convert source items.
msg.Message.Source = make([]SimpleItem, len(nm.Message.Source.SimpleItems))
for j, item := range nm.Message.Source.SimpleItems {
msg.Message.Source[j] = SimpleItem{Name: item.Name, Value: item.Value}
}
// Convert key items.
msg.Message.Key = make([]SimpleItem, len(nm.Message.Key.SimpleItems))
for j, item := range nm.Message.Key.SimpleItems {
msg.Message.Key[j] = SimpleItem{Name: item.Name, Value: item.Value}
}
// Convert data items.
msg.Message.Data = make([]SimpleItem, len(nm.Message.Data.SimpleItems))
for j, item := range nm.Message.Data.SimpleItems {
msg.Message.Data[j] = SimpleItem{Name: item.Name, Value: item.Value}
}
messages[i] = msg
}
return messages, nil
}
// Seek seeks to a specific position in the event stream.
func (c *Client) Seek(ctx context.Context, subscriptionReference string, utcTime time.Time, reverse bool) error {
if subscriptionReference == "" {
return ErrInvalidSubscriptionReference
}
type Seek struct {
XMLName xml.Name `xml:"tev:Seek"`
Xmlns string `xml:"xmlns:tev,attr"`
UtcTime string `xml:"tev:UtcTime"`
Reverse bool `xml:"tev:Reverse,omitempty"`
}
type SeekResponse struct {
XMLName xml.Name `xml:"SeekResponse"`
}
req := Seek{
Xmlns: eventNamespace,
UtcTime: utcTime.Format(time.RFC3339),
Reverse: reverse,
}
var resp SeekResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, subscriptionReference, "", req, &resp); err != nil {
return fmt.Errorf("Seek failed: %w", err)
}
return nil
}
// SetEventSynchronizationPoint instructs the device to send a synchronization point for events.
func (c *Client) SetEventSynchronizationPoint(ctx context.Context, subscriptionReference string) error {
if subscriptionReference == "" {
return ErrInvalidSubscriptionReference
}
type SetSynchronizationPoint struct {
XMLName xml.Name `xml:"tev:SetSynchronizationPoint"`
Xmlns string `xml:"xmlns:tev,attr"`
}
type SetSynchronizationPointResponse struct {
XMLName xml.Name `xml:"SetSynchronizationPointResponse"`
}
req := SetSynchronizationPoint{
Xmlns: eventNamespace,
}
var resp SetSynchronizationPointResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, subscriptionReference, "", req, &resp); err != nil {
return fmt.Errorf("SetSynchronizationPoint failed: %w", err)
}
return nil
}
// Unsubscribe terminates a subscription.
func (c *Client) Unsubscribe(ctx context.Context, subscriptionReference string) error {
if subscriptionReference == "" {
return ErrInvalidSubscriptionReference
}
type Unsubscribe struct {
XMLName xml.Name `xml:"wsnt:Unsubscribe"`
Xmlns string `xml:"xmlns:wsnt,attr"`
}
type UnsubscribeResponse struct {
XMLName xml.Name `xml:"UnsubscribeResponse"`
}
req := Unsubscribe{
Xmlns: "http://docs.oasis-open.org/wsn/b-2",
}
var resp UnsubscribeResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, subscriptionReference, "", req, &resp); err != nil {
return fmt.Errorf("Unsubscribe failed: %w", err)
}
return nil
}
// RenewSubscription renews a subscription with a new termination time.
func (c *Client) RenewSubscription(
ctx context.Context,
subscriptionReference string,
terminationTime time.Duration,
) (time.Time, time.Time, error) {
if subscriptionReference == "" {
return time.Time{}, time.Time{}, ErrInvalidSubscriptionReference
}
if terminationTime <= 0 {
return time.Time{}, time.Time{}, ErrInvalidTerminationTime
}
type Renew struct {
XMLName xml.Name `xml:"wsnt:Renew"`
Xmlns string `xml:"xmlns:wsnt,attr"`
TerminationTime string `xml:"wsnt:TerminationTime"`
}
type RenewResponse struct {
XMLName xml.Name `xml:"RenewResponse"`
CurrentTime string `xml:"CurrentTime"`
TerminationTime string `xml:"TerminationTime"`
}
req := Renew{
Xmlns: "http://docs.oasis-open.org/wsn/b-2",
TerminationTime: formatDuration(terminationTime),
}
var resp RenewResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, subscriptionReference, "", req, &resp); err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("RenewSubscription failed: %w", err)
}
var currentTime, newTerminationTime time.Time
if resp.CurrentTime != "" {
if t, err := time.Parse(time.RFC3339, resp.CurrentTime); err == nil {
currentTime = t
}
}
if resp.TerminationTime != "" {
if t, err := time.Parse(time.RFC3339, resp.TerminationTime); err == nil {
newTerminationTime = t
}
}
return currentTime, newTerminationTime, nil
}
// GetEventProperties retrieves the event properties of the device.
func (c *Client) GetEventProperties(ctx context.Context) (*EventProperties, error) {
endpoint := c.getEventEndpoint()
type GetEventProperties struct {
XMLName xml.Name `xml:"tev:GetEventProperties"`
Xmlns string `xml:"xmlns:tev,attr"`
}
type GetEventPropertiesResponse struct {
XMLName xml.Name `xml:"GetEventPropertiesResponse"`
TopicNamespaceLocation []string `xml:"TopicNamespaceLocation"`
FixedTopicSet bool `xml:"FixedTopicSet"`
TopicExpressionDialect []string `xml:"TopicExpressionDialect"`
MessageContentFilterDialect []string `xml:"MessageContentFilterDialect"`
ProducerPropertiesFilterDialect []string `xml:"ProducerPropertiesFilterDialect"`
MessageContentSchemaLocation []string `xml:"MessageContentSchemaLocation"`
}
req := GetEventProperties{
Xmlns: eventNamespace,
}
var resp GetEventPropertiesResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil {
return nil, fmt.Errorf("GetEventProperties failed: %w", err)
}
properties := &EventProperties{
TopicNamespaceLocation: resp.TopicNamespaceLocation,
FixedTopicSet: resp.FixedTopicSet,
TopicExpressionDialects: resp.TopicExpressionDialect,
MessageContentFilterDialects: resp.MessageContentFilterDialect,
ProducerPropertiesFilterDialects: resp.ProducerPropertiesFilterDialect,
MessageContentSchemaLocation: resp.MessageContentSchemaLocation,
}
return properties, nil
}
// AddEventBroker adds an event broker configuration.
func (c *Client) AddEventBroker(ctx context.Context, config *EventBrokerConfig) error {
if config == nil {
return ErrEventBrokerConfigNil
}
if config.Address == "" {
return ErrInvalidEventBrokerAddress
}
endpoint := c.getEventEndpoint()
type EventBrokerConfigXML struct {
Address string `xml:"tev:Address"`
TopicPrefix string `xml:"tev:TopicPrefix,omitempty"`
UserName string `xml:"tev:UserName,omitempty"`
Password string `xml:"tev:Password,omitempty"`
CertificateID string `xml:"tev:CertificateID,omitempty"`
PublishFilter string `xml:"tev:PublishFilter,omitempty"`
QoS int `xml:"tev:QoS,omitempty"`
CertPathValidation bool `xml:"tev:CertPathValidation,omitempty"`
MetadataFilter string `xml:"tev:MetadataFilter,omitempty"`
}
type AddEventBroker struct {
XMLName xml.Name `xml:"tev:AddEventBroker"`
Xmlns string `xml:"xmlns:tev,attr"`
EventBrokerConfig EventBrokerConfigXML `xml:"tev:EventBrokerConfig"`
}
type AddEventBrokerResponse struct {
XMLName xml.Name `xml:"AddEventBrokerResponse"`
}
req := AddEventBroker{
Xmlns: eventNamespace,
EventBrokerConfig: EventBrokerConfigXML{
Address: config.Address,
TopicPrefix: config.TopicPrefix,
UserName: config.UserName,
Password: config.Password,
CertificateID: config.CertificateID,
PublishFilter: config.PublishFilter,
QoS: config.QoS,
CertPathValidation: config.CertPathValidation,
MetadataFilter: config.MetadataFilter,
},
}
var resp AddEventBrokerResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil {
return fmt.Errorf("AddEventBroker failed: %w", err)
}
return nil
}
// DeleteEventBroker deletes an event broker configuration.
func (c *Client) DeleteEventBroker(ctx context.Context, address string) error {
if address == "" {
return ErrInvalidEventBrokerAddress
}
endpoint := c.getEventEndpoint()
type DeleteEventBroker struct {
XMLName xml.Name `xml:"tev:DeleteEventBroker"`
Xmlns string `xml:"xmlns:tev,attr"`
Address string `xml:"tev:Address"`
}
type DeleteEventBrokerResponse struct {
XMLName xml.Name `xml:"DeleteEventBrokerResponse"`
}
req := DeleteEventBroker{
Xmlns: eventNamespace,
Address: address,
}
var resp DeleteEventBrokerResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil {
return fmt.Errorf("DeleteEventBroker failed: %w", err)
}
return nil
}
// GetEventBrokers retrieves all event broker configurations.
func (c *Client) GetEventBrokers(ctx context.Context) ([]*EventBrokerConfig, error) {
endpoint := c.getEventEndpoint()
type GetEventBrokers struct {
XMLName xml.Name `xml:"tev:GetEventBrokers"`
Xmlns string `xml:"xmlns:tev,attr"`
}
type GetEventBrokersResponse struct {
XMLName xml.Name `xml:"GetEventBrokersResponse"`
EventBrokers []struct {
Address string `xml:"Address"`
TopicPrefix string `xml:"TopicPrefix"`
UserName string `xml:"UserName"`
Password string `xml:"Password"`
CertificateID string `xml:"CertificateID"`
PublishFilter string `xml:"PublishFilter"`
QoS int `xml:"QoS"`
Status string `xml:"Status"`
CertPathValidation bool `xml:"CertPathValidation"`
MetadataFilter string `xml:"MetadataFilter"`
} `xml:"EventBroker"`
}
req := GetEventBrokers{
Xmlns: eventNamespace,
}
var resp GetEventBrokersResponse
username, password := c.GetCredentials()
soapClient := soap.NewClient(c.httpClient, username, password)
if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil {
return nil, fmt.Errorf("GetEventBrokers failed: %w", err)
}
brokers := make([]*EventBrokerConfig, len(resp.EventBrokers))
for i := range resp.EventBrokers {
eb := &resp.EventBrokers[i]
brokers[i] = &EventBrokerConfig{
Address: eb.Address,
TopicPrefix: eb.TopicPrefix,
UserName: eb.UserName,
Password: eb.Password,
CertificateID: eb.CertificateID,
PublishFilter: eb.PublishFilter,
QoS: eb.QoS,
Status: eb.Status,
CertPathValidation: eb.CertPathValidation,
MetadataFilter: eb.MetadataFilter,
}
}
return brokers, nil
}
// formatDuration formats a duration as an ISO 8601 duration string.
func formatDuration(d time.Duration) string {
seconds := int(d.Seconds())
if seconds < 60 { //nolint:mnd // 60 seconds in a minute
return fmt.Sprintf("PT%dS", seconds)
}
minutes := seconds / 60 //nolint:mnd // 60 seconds in a minute
seconds %= 60
if seconds == 0 {
return fmt.Sprintf("PT%dM", minutes)
}
return fmt.Sprintf("PT%dM%dS", minutes, seconds)
}
// splitSpaceSeparated splits a space-separated string into a slice.
func splitSpaceSeparated(s string) []string {
if s == "" {
return nil
}
var result []string
start := 0
inWord := false
for i, r := range s {
if r == ' ' || r == '\t' {
if inWord {
result = append(result, s[start:i])
inWord = false
}
} else {
if !inWord {
start = i
inWord = true
}
}
}
if inWord {
result = append(result, s[start:])
}
return result
}
+738
View File
@@ -0,0 +1,738 @@
package onvif
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
const testEventXMLHeader = `<?xml version="1.0" encoding="UTF-8"?>`
func newMockEventServer() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/soap+xml")
body := make([]byte, r.ContentLength)
_, _ = r.Body.Read(body)
bodyStr := string(body)
var response string
switch {
case strings.Contains(bodyStr, "GetServiceCapabilities"):
response = testEventXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<tev:GetServiceCapabilitiesResponse xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
<tev:Capabilities
WSSubscriptionPolicySupport="true"
WSPausableSubscriptionManagerInterfaceSupport="true"
MaxNotificationProducers="10"
MaxPullPoints="5"
PersistentNotificationStorage="true"
EventBrokerProtocols="mqtt mqtts"
MaxEventBrokers="3"
MetadataOverMQTT="true"/>
</tev:GetServiceCapabilitiesResponse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
case strings.Contains(bodyStr, "CreatePullPointSubscription"):
response = testEventXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<tev:CreatePullPointSubscriptionResponse xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
<tev:SubscriptionReference>
<wsa:Address xmlns:wsa="http://www.w3.org/2005/08/addressing">http://192.168.1.100/onvif/subscription/1</wsa:Address>
</tev:SubscriptionReference>
<tev:CurrentTime>2025-01-15T10:30:00Z</tev:CurrentTime>
<tev:TerminationTime>2025-01-15T11:30:00Z</tev:TerminationTime>
</tev:CreatePullPointSubscriptionResponse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
case strings.Contains(bodyStr, "PullMessages"):
response = testEventXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<tev:PullMessagesResponse xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
<tev:CurrentTime>2025-01-15T10:30:00Z</tev:CurrentTime>
<tev:TerminationTime>2025-01-15T11:30:00Z</tev:TerminationTime>
<wsnt:NotificationMessage xmlns:wsnt="http://docs.oasis-open.org/wsn/b-2">
<wsnt:Topic>tns1:VideoSource/MotionAlarm</wsnt:Topic>
<wsnt:ProducerReference>
<wsa:Address xmlns:wsa="http://www.w3.org/2005/08/addressing">http://192.168.1.100</wsa:Address>
</wsnt:ProducerReference>
<wsnt:Message PropertyOperation="Changed" UtcTime="2025-01-15T10:29:55Z">
<tt:Source xmlns:tt="http://www.onvif.org/ver10/schema">
<tt:SimpleItem Name="VideoSourceToken" Value="video_src_001"/>
</tt:Source>
<tt:Key xmlns:tt="http://www.onvif.org/ver10/schema">
<tt:SimpleItem Name="RuleToken" Value="rule_001"/>
</tt:Key>
<tt:Data xmlns:tt="http://www.onvif.org/ver10/schema">
<tt:SimpleItem Name="State" Value="true"/>
</tt:Data>
</wsnt:Message>
</wsnt:NotificationMessage>
</tev:PullMessagesResponse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
case strings.Contains(bodyStr, "Seek"):
response = testEventXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<tev:SeekResponse xmlns:tev="http://www.onvif.org/ver10/events/wsdl"/>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
case strings.Contains(bodyStr, "SetSynchronizationPoint"):
response = testEventXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<tev:SetSynchronizationPointResponse xmlns:tev="http://www.onvif.org/ver10/events/wsdl"/>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
case strings.Contains(bodyStr, "Unsubscribe"):
response = testEventXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<wsnt:UnsubscribeResponse xmlns:wsnt="http://docs.oasis-open.org/wsn/b-2"/>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
case strings.Contains(bodyStr, "Renew"):
response = testEventXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<wsnt:RenewResponse xmlns:wsnt="http://docs.oasis-open.org/wsn/b-2">
<wsnt:CurrentTime>2025-01-15T10:30:00Z</wsnt:CurrentTime>
<wsnt:TerminationTime>2025-01-15T12:30:00Z</wsnt:TerminationTime>
</wsnt:RenewResponse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
case strings.Contains(bodyStr, "GetEventProperties"):
response = testEventXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<tev:GetEventPropertiesResponse xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
<tev:TopicNamespaceLocation>http://www.onvif.org/onvif/ver10/topics/topicns.xml</tev:TopicNamespaceLocation>
<tev:FixedTopicSet>true</tev:FixedTopicSet>
<tev:TopicExpressionDialect>http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet</tev:TopicExpressionDialect>
<tev:MessageContentFilterDialect>http://www.onvif.org/ver10/tev/messageContentFilter/ItemFilter</tev:MessageContentFilterDialect>
<tev:ProducerPropertiesFilterDialect>http://www.onvif.org/ver10/tev/producerPropertiesFilter</tev:ProducerPropertiesFilterDialect>
<tev:MessageContentSchemaLocation>http://www.onvif.org/onvif/ver10/schema/onvif.xsd</tev:MessageContentSchemaLocation>
</tev:GetEventPropertiesResponse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
case strings.Contains(bodyStr, "AddEventBroker"):
response = testEventXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<tev:AddEventBrokerResponse xmlns:tev="http://www.onvif.org/ver10/events/wsdl"/>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
case strings.Contains(bodyStr, "DeleteEventBroker"):
response = testEventXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<tev:DeleteEventBrokerResponse xmlns:tev="http://www.onvif.org/ver10/events/wsdl"/>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
case strings.Contains(bodyStr, "GetEventBrokers"):
response = testEventXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<tev:GetEventBrokersResponse xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
<tev:EventBroker>
<tev:Address>mqtt://broker.example.com:1883</tev:Address>
<tev:TopicPrefix>onvif/</tev:TopicPrefix>
<tev:UserName>mqtt_user</tev:UserName>
<tev:QoS>1</tev:QoS>
<tev:Status>Connected</tev:Status>
<tev:CertPathValidation>true</tev:CertPathValidation>
</tev:EventBroker>
</tev:GetEventBrokersResponse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
default:
response = testEventXMLHeader + `
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope">
<SOAP-ENV:Body>
<SOAP-ENV:Fault>
<SOAP-ENV:Code><SOAP-ENV:Value>SOAP-ENV:Receiver</SOAP-ENV:Value></SOAP-ENV:Code>
<SOAP-ENV:Reason><SOAP-ENV:Text>Unknown action</SOAP-ENV:Text></SOAP-ENV:Reason>
</SOAP-ENV:Fault>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
}
_, _ = w.Write([]byte(response))
}))
}
func TestGetEventServiceCapabilities(t *testing.T) {
server := newMockEventServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
caps, err := client.GetEventServiceCapabilities(ctx)
if err != nil {
t.Fatalf("GetEventServiceCapabilities failed: %v", err)
}
if !caps.WSSubscriptionPolicySupport {
t.Error("Expected WSSubscriptionPolicySupport to be true")
}
if !caps.WSPausableSubscriptionManagerInterfaceSupport {
t.Error("Expected WSPausableSubscriptionManagerInterfaceSupport to be true")
}
if caps.MaxNotificationProducers != 10 {
t.Errorf("Expected MaxNotificationProducers to be 10, got %d", caps.MaxNotificationProducers)
}
if caps.MaxPullPoints != 5 {
t.Errorf("Expected MaxPullPoints to be 5, got %d", caps.MaxPullPoints)
}
if !caps.PersistentNotificationStorage {
t.Error("Expected PersistentNotificationStorage to be true")
}
if len(caps.EventBrokerProtocols) != 2 {
t.Errorf("Expected 2 EventBrokerProtocols, got %d", len(caps.EventBrokerProtocols))
}
if caps.MaxEventBrokers != 3 {
t.Errorf("Expected MaxEventBrokers to be 3, got %d", caps.MaxEventBrokers)
}
if !caps.MetadataOverMQTT {
t.Error("Expected MetadataOverMQTT to be true")
}
}
func TestCreatePullPointSubscription(t *testing.T) {
server := newMockEventServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
// Test with no filter and default termination time.
sub, err := client.CreatePullPointSubscription(ctx, "", nil, "")
if err != nil {
t.Fatalf("CreatePullPointSubscription failed: %v", err)
}
if sub.SubscriptionReference == "" {
t.Error("Expected SubscriptionReference to be set")
}
if sub.CurrentTime.IsZero() {
t.Error("Expected CurrentTime to be set")
}
if sub.TerminationTime.IsZero() {
t.Error("Expected TerminationTime to be set")
}
// Test with filter and termination time.
termTime := 1 * time.Hour
sub2, err := client.CreatePullPointSubscription(ctx, "tns1:VideoSource/MotionAlarm", &termTime, "policy1")
if err != nil {
t.Fatalf("CreatePullPointSubscription with filter failed: %v", err)
}
if sub2.SubscriptionReference == "" {
t.Error("Expected SubscriptionReference to be set")
}
}
func TestCreatePullPointSubscriptionInvalidTerminationTime(t *testing.T) {
server := newMockEventServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
// Test with invalid (negative) termination time.
invalidTime := -1 * time.Hour
_, err = client.CreatePullPointSubscription(ctx, "", &invalidTime, "")
if !errors.Is(err, ErrInvalidTerminationTime) {
t.Errorf("Expected ErrInvalidTerminationTime, got %v", err)
}
}
func TestPullMessages(t *testing.T) {
server := newMockEventServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
messages, err := client.PullMessages(ctx, server.URL+"/subscription/1", 30*time.Second, 10)
if err != nil {
t.Fatalf("PullMessages failed: %v", err)
}
if len(messages) == 0 {
t.Error("Expected at least one notification message")
}
if len(messages) > 0 {
msg := messages[0]
if msg.Topic == "" {
t.Error("Expected Topic to be set")
}
if msg.Message.PropertyOperation == "" {
t.Error("Expected PropertyOperation to be set")
}
if len(msg.Message.Source) == 0 {
t.Error("Expected Source items to be present")
}
if len(msg.Message.Data) == 0 {
t.Error("Expected Data items to be present")
}
}
}
func TestPullMessagesValidation(t *testing.T) {
server := newMockEventServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
// Test empty subscription reference.
_, err = client.PullMessages(ctx, "", 30*time.Second, 10)
if !errors.Is(err, ErrInvalidSubscriptionReference) {
t.Errorf("Expected ErrInvalidSubscriptionReference, got %v", err)
}
// Test invalid timeout.
_, err = client.PullMessages(ctx, server.URL+"/subscription/1", 0, 10)
if !errors.Is(err, ErrInvalidTimeout) {
t.Errorf("Expected ErrInvalidTimeout, got %v", err)
}
// Test invalid message limit.
_, err = client.PullMessages(ctx, server.URL+"/subscription/1", 30*time.Second, 0)
if !errors.Is(err, ErrInvalidMessageLimit) {
t.Errorf("Expected ErrInvalidMessageLimit, got %v", err)
}
}
func TestSeek(t *testing.T) {
server := newMockEventServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
err = client.Seek(ctx, server.URL+"/subscription/1", time.Now().Add(-1*time.Hour), false)
if err != nil {
t.Fatalf("Seek failed: %v", err)
}
// Test with reverse.
err = client.Seek(ctx, server.URL+"/subscription/1", time.Now().Add(-1*time.Hour), true)
if err != nil {
t.Fatalf("Seek with reverse failed: %v", err)
}
}
func TestSeekInvalidSubscriptionReference(t *testing.T) {
server := newMockEventServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
err = client.Seek(ctx, "", time.Now(), false)
if !errors.Is(err, ErrInvalidSubscriptionReference) {
t.Errorf("Expected ErrInvalidSubscriptionReference, got %v", err)
}
}
func TestSetEventSynchronizationPoint(t *testing.T) {
server := newMockEventServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
err = client.SetEventSynchronizationPoint(ctx, server.URL+"/subscription/1")
if err != nil {
t.Fatalf("SetEventSynchronizationPoint failed: %v", err)
}
}
func TestSetEventSynchronizationPointInvalidSubscriptionReference(t *testing.T) {
server := newMockEventServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
err = client.SetEventSynchronizationPoint(ctx, "")
if !errors.Is(err, ErrInvalidSubscriptionReference) {
t.Errorf("Expected ErrInvalidSubscriptionReference, got %v", err)
}
}
func TestUnsubscribe(t *testing.T) {
server := newMockEventServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
err = client.Unsubscribe(ctx, server.URL+"/subscription/1")
if err != nil {
t.Fatalf("Unsubscribe failed: %v", err)
}
}
func TestUnsubscribeInvalidSubscriptionReference(t *testing.T) {
server := newMockEventServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
err = client.Unsubscribe(ctx, "")
if !errors.Is(err, ErrInvalidSubscriptionReference) {
t.Errorf("Expected ErrInvalidSubscriptionReference, got %v", err)
}
}
func TestRenewSubscription(t *testing.T) {
server := newMockEventServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
currentTime, terminationTime, err := client.RenewSubscription(ctx, server.URL+"/subscription/1", 2*time.Hour)
if err != nil {
t.Fatalf("RenewSubscription failed: %v", err)
}
if currentTime.IsZero() {
t.Error("Expected CurrentTime to be set")
}
if terminationTime.IsZero() {
t.Error("Expected TerminationTime to be set")
}
}
func TestRenewSubscriptionValidation(t *testing.T) {
server := newMockEventServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
// Test empty subscription reference.
_, _, err = client.RenewSubscription(ctx, "", time.Hour)
if !errors.Is(err, ErrInvalidSubscriptionReference) {
t.Errorf("Expected ErrInvalidSubscriptionReference, got %v", err)
}
// Test invalid termination time.
_, _, err = client.RenewSubscription(ctx, server.URL+"/subscription/1", 0)
if !errors.Is(err, ErrInvalidTerminationTime) {
t.Errorf("Expected ErrInvalidTerminationTime, got %v", err)
}
}
func TestGetEventProperties(t *testing.T) {
server := newMockEventServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
props, err := client.GetEventProperties(ctx)
if err != nil {
t.Fatalf("GetEventProperties failed: %v", err)
}
if len(props.TopicNamespaceLocation) == 0 {
t.Error("Expected TopicNamespaceLocation to be set")
}
if !props.FixedTopicSet {
t.Error("Expected FixedTopicSet to be true")
}
if len(props.TopicExpressionDialects) == 0 {
t.Error("Expected TopicExpressionDialects to be set")
}
if len(props.MessageContentFilterDialects) == 0 {
t.Error("Expected MessageContentFilterDialects to be set")
}
}
func TestAddEventBroker(t *testing.T) {
server := newMockEventServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
config := &EventBrokerConfig{
Address: "mqtt://broker.example.com:1883",
TopicPrefix: "onvif/",
UserName: "mqtt_user",
Password: "mqtt_pass",
QoS: 1,
}
err = client.AddEventBroker(ctx, config)
if err != nil {
t.Fatalf("AddEventBroker failed: %v", err)
}
}
func TestAddEventBrokerValidation(t *testing.T) {
server := newMockEventServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
// Test nil config.
err = client.AddEventBroker(ctx, nil)
if err == nil {
t.Error("Expected error for nil config")
}
// Test empty address.
config := &EventBrokerConfig{Address: ""}
err = client.AddEventBroker(ctx, config)
if !errors.Is(err, ErrInvalidEventBrokerAddress) {
t.Errorf("Expected ErrInvalidEventBrokerAddress, got %v", err)
}
}
func TestDeleteEventBroker(t *testing.T) {
server := newMockEventServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
err = client.DeleteEventBroker(ctx, "mqtt://broker.example.com:1883")
if err != nil {
t.Fatalf("DeleteEventBroker failed: %v", err)
}
}
func TestDeleteEventBrokerInvalidAddress(t *testing.T) {
server := newMockEventServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
err = client.DeleteEventBroker(ctx, "")
if !errors.Is(err, ErrInvalidEventBrokerAddress) {
t.Errorf("Expected ErrInvalidEventBrokerAddress, got %v", err)
}
}
func TestGetEventBrokers(t *testing.T) {
server := newMockEventServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
brokers, err := client.GetEventBrokers(ctx)
if err != nil {
t.Fatalf("GetEventBrokers failed: %v", err)
}
if len(brokers) == 0 {
t.Error("Expected at least one event broker")
}
if len(brokers) > 0 {
broker := brokers[0]
if broker.Address == "" {
t.Error("Expected Address to be set")
}
if broker.TopicPrefix == "" {
t.Error("Expected TopicPrefix to be set")
}
if broker.Status == "" {
t.Error("Expected Status to be set")
}
}
}
func TestFormatDuration(t *testing.T) {
tests := []struct {
duration time.Duration
expected string
}{
{30 * time.Second, "PT30S"},
{60 * time.Second, "PT1M"},
{90 * time.Second, "PT1M30S"},
{5 * time.Minute, "PT5M"},
{65 * time.Second, "PT1M5S"},
}
for _, tt := range tests {
result := formatDuration(tt.duration)
if result != tt.expected {
t.Errorf("formatDuration(%v) = %s, expected %s", tt.duration, result, tt.expected)
}
}
}
func TestSplitSpaceSeparated(t *testing.T) {
tests := []struct {
input string
expected []string
}{
{"", nil},
{"mqtt", []string{"mqtt"}},
{"mqtt mqtts", []string{"mqtt", "mqtts"}},
{" mqtt mqtts ", []string{"mqtt", "mqtts"}},
{"a b c", []string{"a", "b", "c"}},
}
for _, tt := range tests {
result := splitSpaceSeparated(tt.input)
if len(result) != len(tt.expected) {
t.Errorf("splitSpaceSeparated(%q) returned %d items, expected %d", tt.input, len(result), len(tt.expected))
continue
}
for i, v := range result {
if v != tt.expected[i] {
t.Errorf("splitSpaceSeparated(%q)[%d] = %q, expected %q", tt.input, i, v, tt.expected[i])
}
}
}
}
func TestSetEventEndpoint(t *testing.T) {
server := newMockEventServer()
defer server.Close()
client, err := NewClient(server.URL, WithCredentials("admin", "password"))
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
newEndpoint := "http://192.168.1.100/onvif/events"
client.SetEventEndpoint(newEndpoint)
// Verify endpoint was set.
endpoint := client.getEventEndpoint()
if endpoint != newEndpoint {
t.Errorf("Expected event endpoint %s, got %s", newEndpoint, endpoint)
}
}
+235
View File
@@ -0,0 +1,235 @@
// Package main tests Event and Device IO services against a real camera.
package main
import (
"context"
"flag"
"fmt"
"os"
"time"
onvif "github.com/0x524a/onvif-go"
)
const notAvailable = "N/A"
func main() {
// Command line flags.
cameraIP := flag.String("ip", "192.168.1.201", "Camera IP address")
username := flag.String("user", "service", "Camera username")
password := flag.String("pass", "Service.1234", "Camera password")
flag.Parse()
endpoint := fmt.Sprintf("http://%s/onvif/device_service", *cameraIP)
fmt.Printf("Testing Event and Device IO services on camera: %s\n", *cameraIP)
fmt.Printf("Endpoint: %s\n", endpoint)
fmt.Printf("Username: %s\n\n", *username)
// Create client.
client, err := onvif.NewClient(endpoint,
onvif.WithCredentials(*username, *password),
onvif.WithTimeout(30*time.Second),
)
if err != nil {
fmt.Printf("Failed to create client: %v\n", err)
os.Exit(1)
}
ctx := context.Background()
// Test device information first to verify connectivity.
fmt.Println("=== Testing Device Connectivity ===")
info, err := client.GetDeviceInformation(ctx)
if err != nil {
fmt.Printf("Failed to get device information: %v\n", err)
os.Exit(1)
}
fmt.Printf("Device: %s %s\n", info.Manufacturer, info.Model)
fmt.Printf("Firmware: %s\n", info.FirmwareVersion)
fmt.Printf("Serial: %s\n\n", info.SerialNumber)
// Test Event Service.
testEventService(ctx, client)
// Test Device IO Service.
testDeviceIOService(ctx, client)
fmt.Println("\n=== All Tests Completed ===")
}
func testEventService(ctx context.Context, client *onvif.Client) {
fmt.Println("=== Testing Event Service ===")
// 1. Get Event Service Capabilities.
fmt.Println("\n1. GetEventServiceCapabilities")
caps, err := client.GetEventServiceCapabilities(ctx)
if err != nil {
fmt.Printf(" ERROR: %v\n", err)
} else {
fmt.Printf(" WSSubscriptionPolicySupport: %v\n", caps.WSSubscriptionPolicySupport)
fmt.Printf(" MaxPullPoints: %d\n", caps.MaxPullPoints)
fmt.Printf(" PersistentNotificationStorage: %v\n", caps.PersistentNotificationStorage)
fmt.Printf(" EventBrokerProtocols: %v\n", caps.EventBrokerProtocols)
fmt.Printf(" MaxEventBrokers: %d\n", caps.MaxEventBrokers)
}
// 2. Get Event Properties.
fmt.Println("\n2. GetEventProperties")
props, err := client.GetEventProperties(ctx)
if err != nil {
fmt.Printf(" ERROR: %v\n", err)
} else {
fmt.Printf(" FixedTopicSet: %v\n", props.FixedTopicSet)
fmt.Printf(" TopicNamespaceLocations: %d\n", len(props.TopicNamespaceLocation))
fmt.Printf(" TopicExpressionDialects: %d\n", len(props.TopicExpressionDialects))
}
// 3. Create Pull Point Subscription.
fmt.Println("\n3. CreatePullPointSubscription")
termTime := 60 * time.Second
sub, err := client.CreatePullPointSubscription(ctx, "", &termTime, "")
if err != nil {
fmt.Printf(" ERROR: %v\n", err)
} else {
fmt.Printf(" SubscriptionReference: %s\n", sub.SubscriptionReference)
fmt.Printf(" CurrentTime: %v\n", sub.CurrentTime)
fmt.Printf(" TerminationTime: %v\n", sub.TerminationTime)
// 4. Pull Messages.
if sub.SubscriptionReference != "" {
fmt.Println("\n4. PullMessages")
messages, err := client.PullMessages(ctx, sub.SubscriptionReference, 5*time.Second, 10)
if err != nil {
fmt.Printf(" ERROR: %v\n", err)
} else {
fmt.Printf(" Received %d messages\n", len(messages))
for i, msg := range messages {
if i >= 3 {
fmt.Printf(" ... and %d more\n", len(messages)-3)
break
}
fmt.Printf(" Message %d: Topic=%s, Operation=%s\n",
i+1, msg.Topic, msg.Message.PropertyOperation)
}
}
// 5. Renew Subscription.
fmt.Println("\n5. RenewSubscription")
curTime, newTermTime, err := client.RenewSubscription(ctx, sub.SubscriptionReference, 120*time.Second)
if err != nil {
fmt.Printf(" ERROR: %v\n", err)
} else {
fmt.Printf(" CurrentTime: %v\n", curTime)
fmt.Printf(" NewTerminationTime: %v\n", newTermTime)
}
// 6. Unsubscribe.
fmt.Println("\n6. Unsubscribe")
err = client.Unsubscribe(ctx, sub.SubscriptionReference)
if err != nil {
fmt.Printf(" ERROR: %v\n", err)
} else {
fmt.Println(" Successfully unsubscribed")
}
}
}
// 7. Get Event Brokers (optional, may not be supported).
fmt.Println("\n7. GetEventBrokers")
brokers, err := client.GetEventBrokers(ctx)
if err != nil {
fmt.Printf(" ERROR (may not be supported): %v\n", err)
} else {
fmt.Printf(" Found %d event brokers\n", len(brokers))
for i, broker := range brokers {
fmt.Printf(" Broker %d: %s (Status: %s)\n", i+1, broker.Address, broker.Status)
}
}
}
func testDeviceIOService(ctx context.Context, client *onvif.Client) {
fmt.Println("\n=== Testing Device IO Service ===")
// 1. Get Device IO Service Capabilities.
fmt.Println("\n1. GetDeviceIOServiceCapabilities")
caps, err := client.GetDeviceIOServiceCapabilities(ctx)
if err != nil {
fmt.Printf(" ERROR: %v\n", err)
} else {
fmt.Printf(" VideoSources: %d\n", caps.VideoSources)
fmt.Printf(" VideoOutputs: %d\n", caps.VideoOutputs)
fmt.Printf(" AudioSources: %d\n", caps.AudioSources)
fmt.Printf(" AudioOutputs: %d\n", caps.AudioOutputs)
fmt.Printf(" RelayOutputs: %d\n", caps.RelayOutputs)
fmt.Printf(" DigitalInputs: %d\n", caps.DigitalInputs)
fmt.Printf(" SerialPorts: %d\n", caps.SerialPorts)
}
// 2. Get Digital Inputs.
fmt.Println("\n2. GetDigitalInputs")
inputs, err := client.GetDigitalInputs(ctx)
if err != nil {
fmt.Printf(" ERROR: %v\n", err)
} else {
fmt.Printf(" Found %d digital inputs\n", len(inputs))
for i, input := range inputs {
fmt.Printf(" Input %d: Token=%s, IdleState=%s\n", i+1, input.Token, input.IdleState)
}
}
// 3. Get Video Outputs.
fmt.Println("\n3. GetVideoOutputs")
outputs, err := client.GetVideoOutputs(ctx)
if err != nil {
fmt.Printf(" ERROR: %v\n", err)
} else {
fmt.Printf(" Found %d video outputs\n", len(outputs))
for i, output := range outputs {
res := notAvailable
if output.Resolution != nil {
res = fmt.Sprintf("%dx%d", output.Resolution.Width, output.Resolution.Height)
}
fmt.Printf(" Output %d: Token=%s, Resolution=%s, RefreshRate=%.1f\n",
i+1, output.Token, res, output.RefreshRate)
}
}
// 4. Get Serial Ports.
fmt.Println("\n4. GetSerialPorts")
ports, err := client.GetSerialPorts(ctx)
if err != nil {
fmt.Printf(" ERROR: %v\n", err)
} else {
fmt.Printf(" Found %d serial ports\n", len(ports))
for i, port := range ports {
fmt.Printf(" Port %d: Token=%s, Type=%s\n", i+1, port.Token, port.Type)
}
}
// 5. Get Relay Outputs (using existing method).
fmt.Println("\n5. GetRelayOutputs")
relays, err := client.GetRelayOutputs(ctx)
if err != nil {
fmt.Printf(" ERROR: %v\n", err)
} else {
fmt.Printf(" Found %d relay outputs\n", len(relays))
for i, relay := range relays {
mode := notAvailable
idleState := notAvailable
if relay.Properties.Mode != "" {
mode = string(relay.Properties.Mode)
}
if relay.Properties.IdleState != "" {
idleState = string(relay.Properties.IdleState)
}
fmt.Printf(" Relay %d: Token=%s, Mode=%s, IdleState=%s\n",
i+1, relay.Token, mode, idleState)
}
}
}