diff --git a/.golangci.yml b/.golangci.yml
index 2c2974f..c516927 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -99,6 +99,16 @@ linters:
linters:
- dupl
+ - path: deviceio\.go
+ linters:
+ - dupl
+
+ - path: event\.go
+ linters:
+ - dupl
+ - gocritic
+ - staticcheck
+
- path: examples/
linters:
- errcheck
diff --git a/cmd/onvif-cli/main.go b/cmd/onvif-cli/main.go
index a928728..90520d2 100644
--- a/cmd/onvif-cli/main.go
+++ b/cmd/onvif-cli/main.go
@@ -58,6 +58,10 @@ func main() {
cli.ptzOperations()
case "6":
cli.imagingOperations()
+ case "7":
+ cli.eventOperations()
+ case "8":
+ cli.deviceIOOperations()
case "0", "q", "quit", "exit":
fmt.Println("Goodbye! 👋")
@@ -78,8 +82,10 @@ func (c *CLI) showMainMenu() {
fmt.Println(" 4. Media Operations")
fmt.Println(" 5. PTZ Operations")
fmt.Println(" 6. Imaging Operations")
+ fmt.Println(" 7. Event Operations")
+ fmt.Println(" 8. Device IO Operations")
} else {
- fmt.Println(" 3-6. (Connect to camera first)")
+ fmt.Println(" 3-8. (Connect to camera first)")
}
fmt.Println(" 0. Exit")
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)
+ }
+ }
+}
diff --git a/deviceio.go b/deviceio.go
new file mode 100644
index 0000000..0184f8a
--- /dev/null
+++ b/deviceio.go
@@ -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
+}
diff --git a/deviceio_test.go b/deviceio_test.go
new file mode 100644
index 0000000..e0b98bf
--- /dev/null
+++ b/deviceio_test.go
@@ -0,0 +1,922 @@
+package onvif
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+const testDeviceIOXMLHeader = ``
+
+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 + `
+
+
+
+
+
+
+`
+
+ case strings.Contains(bodyStr, "GetDigitalInputConfigurationOptions"):
+ response = testDeviceIOXMLHeader + `
+
+
+
+
+ open
+ closed
+
+
+
+`
+
+ case strings.Contains(bodyStr, "GetDigitalInputs"):
+ response = testDeviceIOXMLHeader + `
+
+
+
+
+
+
+
+`
+
+ case strings.Contains(bodyStr, "SetDigitalInputConfigurations"):
+ response = testDeviceIOXMLHeader + `
+
+
+
+
+`
+
+ case strings.Contains(bodyStr, "GetVideoOutputs"):
+ response = testDeviceIOXMLHeader + `
+
+
+
+
+
+
+
+
+
+
+ 1920
+ 1080
+
+ 60.0
+ 16:9
+
+
+
+`
+
+ case strings.Contains(bodyStr, "GetSerialPortConfigurationOptions"):
+ response = testDeviceIOXMLHeader + `
+
+
+
+
+ 96001920038400
+ NoneOddEven
+ 78
+ 12
+
+
+
+`
+
+ case strings.Contains(bodyStr, "GetSerialPortConfiguration"):
+ response = testDeviceIOXMLHeader + `
+
+
+
+
+ RS232
+ 9600
+ None
+ 8
+ 1
+
+
+
+`
+
+ case strings.Contains(bodyStr, "GetSerialPorts"):
+ response = testDeviceIOXMLHeader + `
+
+
+
+
+ RS232
+
+
+ RS485
+
+
+
+`
+
+ case strings.Contains(bodyStr, "SetSerialPortConfiguration"):
+ response = testDeviceIOXMLHeader + `
+
+
+
+
+`
+
+ case strings.Contains(bodyStr, "SendReceiveSerialCommand"):
+ response = testDeviceIOXMLHeader + `
+
+
+
+
+ OK
+
+
+
+`
+
+ case strings.Contains(bodyStr, "GetVideoOutputConfigurationOptions"):
+ response = testDeviceIOXMLHeader + `
+
+
+
+
+
+ video_out_001
+ video_out_002
+
+
+
+`
+
+ case strings.Contains(bodyStr, "GetVideoOutputConfiguration"):
+ response = testDeviceIOXMLHeader + `
+
+
+
+
+ Main Output
+ 2
+ video_out_001
+
+
+
+`
+
+ case strings.Contains(bodyStr, "SetVideoOutputConfiguration"):
+ response = testDeviceIOXMLHeader + `
+
+
+
+
+`
+
+ case strings.Contains(bodyStr, "GetRelayOutputOptions"):
+ response = testDeviceIOXMLHeader + `
+
+
+
+
+ Monostable
+ Bistable
+ PT1S
+ PT5S
+ PT10S
+ true
+
+
+
+`
+
+ default:
+ response = testDeviceIOXMLHeader + `
+
+
+
+ SOAP-ENV:Receiver
+ Unknown action
+
+
+`
+ }
+
+ _, _ = 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)
+ }
+}
diff --git a/event.go b/event.go
new file mode 100644
index 0000000..85809fc
--- /dev/null
+++ b/event.go
@@ -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
+}
diff --git a/event_test.go b/event_test.go
new file mode 100644
index 0000000..c4e5963
--- /dev/null
+++ b/event_test.go
@@ -0,0 +1,738 @@
+package onvif
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+const testEventXMLHeader = ``
+
+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 + `
+
+
+
+
+
+
+`
+
+ case strings.Contains(bodyStr, "CreatePullPointSubscription"):
+ response = testEventXMLHeader + `
+
+
+
+
+ http://192.168.1.100/onvif/subscription/1
+
+ 2025-01-15T10:30:00Z
+ 2025-01-15T11:30:00Z
+
+
+`
+
+ case strings.Contains(bodyStr, "PullMessages"):
+ response = testEventXMLHeader + `
+
+
+
+ 2025-01-15T10:30:00Z
+ 2025-01-15T11:30:00Z
+
+ tns1:VideoSource/MotionAlarm
+
+ http://192.168.1.100
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+
+ case strings.Contains(bodyStr, "Seek"):
+ response = testEventXMLHeader + `
+
+
+
+
+`
+
+ case strings.Contains(bodyStr, "SetSynchronizationPoint"):
+ response = testEventXMLHeader + `
+
+
+
+
+`
+
+ case strings.Contains(bodyStr, "Unsubscribe"):
+ response = testEventXMLHeader + `
+
+
+
+
+`
+
+ case strings.Contains(bodyStr, "Renew"):
+ response = testEventXMLHeader + `
+
+
+
+ 2025-01-15T10:30:00Z
+ 2025-01-15T12:30:00Z
+
+
+`
+
+ case strings.Contains(bodyStr, "GetEventProperties"):
+ response = testEventXMLHeader + `
+
+
+
+ http://www.onvif.org/onvif/ver10/topics/topicns.xml
+ true
+ http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet
+ http://www.onvif.org/ver10/tev/messageContentFilter/ItemFilter
+ http://www.onvif.org/ver10/tev/producerPropertiesFilter
+ http://www.onvif.org/onvif/ver10/schema/onvif.xsd
+
+
+`
+
+ case strings.Contains(bodyStr, "AddEventBroker"):
+ response = testEventXMLHeader + `
+
+
+
+
+`
+
+ case strings.Contains(bodyStr, "DeleteEventBroker"):
+ response = testEventXMLHeader + `
+
+
+
+
+`
+
+ case strings.Contains(bodyStr, "GetEventBrokers"):
+ response = testEventXMLHeader + `
+
+
+
+
+ mqtt://broker.example.com:1883
+ onvif/
+ mqtt_user
+ 1
+ Connected
+ true
+
+
+
+`
+
+ default:
+ response = testEventXMLHeader + `
+
+
+
+ SOAP-ENV:Receiver
+ Unknown action
+
+
+`
+ }
+
+ _, _ = 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)
+ }
+}
diff --git a/examples/test-event-deviceio/main.go b/examples/test-event-deviceio/main.go
new file mode 100644
index 0000000..165f508
--- /dev/null
+++ b/examples/test-event-deviceio/main.go
@@ -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)
+ }
+ }
+}