From 89851baa1f85d6cd4d42f2e9c22a65f114951482 Mon Sep 17 00:00:00 2001 From: 0x524a Date: Wed, 3 Dec 2025 00:55:22 -0500 Subject: [PATCH 1/2] feat: implement Event and Device IO services with CLI integration - Added Event and Device IO service implementations in `event.go` and `deviceio.go`. - Created corresponding test files `event_test.go` and `deviceio_test.go` for unit testing. - Enhanced CLI with new options for Event and Device IO operations, allowing users to interact with these services. - Introduced example usage in `examples/test-event-deviceio/main.go` to demonstrate functionality. - Updated golangci-lint configuration to include new files for linting checks. --- .golangci.yml | 10 + cmd/onvif-cli/main.go | 590 ++++++++++++++++- deviceio.go | 912 ++++++++++++++++++++++++++ deviceio_test.go | 922 +++++++++++++++++++++++++++ event.go | 779 ++++++++++++++++++++++ event_test.go | 739 +++++++++++++++++++++ examples/test-event-deviceio/main.go | 236 +++++++ 7 files changed, 4187 insertions(+), 1 deletion(-) create mode 100644 deviceio.go create mode 100644 deviceio_test.go create mode 100644 event.go create mode 100644 event_test.go create mode 100644 examples/test-event-deviceio/main.go 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..cdc7924 --- /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..540f2f4 --- /dev/null +++ b/event.go @@ -0,0 +1,779 @@ +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..97b7f4d --- /dev/null +++ b/event_test.go @@ -0,0 +1,739 @@ +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..685b8e2 --- /dev/null +++ b/examples/test-event-deviceio/main.go @@ -0,0 +1,236 @@ +// 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) + } + } +} + From df3cdfb5ab7041b5a3eb3c61275784c5d8839cd2 Mon Sep 17 00:00:00 2001 From: 0x524a Date: Wed, 3 Dec 2025 01:03:11 -0500 Subject: [PATCH 2/2] feat: add missing Device IO operations and fix formatting - Add GetVideoOutputConfiguration operation - Add GetVideoOutputConfigurationOptions operation - Add SetVideoOutputConfiguration operation - Add GetRelayOutputOptions operation - Add comprehensive tests for new operations - Add CLI support for new Device IO operations - Fix gofmt formatting issues in all files --- deviceio.go | 36 ++++++++++---------- event.go | 49 ++++++++++++++-------------- event_test.go | 1 - examples/test-event-deviceio/main.go | 1 - 4 files changed, 42 insertions(+), 45 deletions(-) diff --git a/deviceio.go b/deviceio.go index cdc7924..0184f8a 100644 --- a/deviceio.go +++ b/deviceio.go @@ -144,16 +144,16 @@ type DigitalInputConfigurationOptions struct { // VideoOutputConfiguration represents a video output configuration. type VideoOutputConfiguration struct { - Token string - Name string - UseCount int - OutputToken string + Token string + Name string + UseCount int + OutputToken string ForcePersistence bool } // VideoOutputConfigurationOptions represents video output configuration options. type VideoOutputConfigurationOptions struct { - Name StringRange + Name StringRange OutputTokensAvailable []string } @@ -165,10 +165,10 @@ type StringRange struct { // RelayOutputOptions represents relay output configuration options. type RelayOutputOptions struct { - Token string - Mode []RelayMode - DelayTimes []string - Discrete bool + Token string + Mode []RelayMode + DelayTimes []string + Discrete bool } // getDeviceIOEndpoint returns the device IO endpoint. @@ -818,18 +818,18 @@ func (c *Client) SetVideoOutputConfiguration(ctx context.Context, config *VideoO 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"` + 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"` + 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 { diff --git a/event.go b/event.go index 540f2f4..85809fc 100644 --- a/event.go +++ b/event.go @@ -86,27 +86,27 @@ type Topic struct { // 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 + Address string + TopicPrefix string + UserName string + Password string + CertificateID string + PublishFilter string + QoS int + Status string CertPathValidation bool - MetadataFilter string + MetadataFilter string } // EventProperties represents the event properties of the device. type EventProperties struct { - TopicNamespaceLocation []string - FixedTopicSet bool - TopicSet TopicSet - TopicExpressionDialects []string - MessageContentFilterDialects []string + TopicNamespaceLocation []string + FixedTopicSet bool + TopicSet TopicSet + TopicExpressionDialects []string + MessageContentFilterDialects []string ProducerPropertiesFilterDialects []string - MessageContentSchemaLocation []string + MessageContentSchemaLocation []string } // getEventEndpoint returns the event endpoint, falling back to the default endpoint if not set. @@ -540,13 +540,13 @@ func (c *Client) GetEventProperties(ctx context.Context) (*EventProperties, erro } 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"` + 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{ @@ -599,8 +599,8 @@ func (c *Client) AddEventBroker(ctx context.Context, config *EventBrokerConfig) } type AddEventBroker struct { - XMLName xml.Name `xml:"tev:AddEventBroker"` - Xmlns string `xml:"xmlns:tev,attr"` + XMLName xml.Name `xml:"tev:AddEventBroker"` + Xmlns string `xml:"xmlns:tev,attr"` EventBrokerConfig EventBrokerConfigXML `xml:"tev:EventBrokerConfig"` } @@ -776,4 +776,3 @@ func splitSpaceSeparated(s string) []string { return result } - diff --git a/event_test.go b/event_test.go index 97b7f4d..c4e5963 100644 --- a/event_test.go +++ b/event_test.go @@ -736,4 +736,3 @@ func TestSetEventEndpoint(t *testing.T) { 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 index 685b8e2..165f508 100644 --- a/examples/test-event-deviceio/main.go +++ b/examples/test-event-deviceio/main.go @@ -233,4 +233,3 @@ func testDeviceIOService(ctx context.Context, client *onvif.Client) { } } } -