From 7d73da7986bb86a9daa2f44b9ba3267af1ce4ad2 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Thu, 8 Jan 2026 23:46:34 +0100 Subject: [PATCH] feat: implement POST /key endpoint for media controls with host:port parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major Features: • POST /key endpoint implementation with XML model and validation • Comprehensive media control commands (play, pause, stop, volume, presets) • Automatic host:port parsing in CLI for improved UX • Production-ready with full test coverage Key Control Implementation: • Add Key model with XML marshaling and validation (pkg/models/key.go) • Support all standard keys: PLAY, PAUSE, STOP, PREV_TRACK, NEXT_TRACK, VOLUME_UP/DOWN, PRESET_1-6 • Client methods: SendKey(), Play(), Pause(), Stop(), VolumeUp(), VolumeDown(), SelectPreset() • CLI commands: -play, -pause, -stop, -next, -prev, -volume-up, -volume-down, -preset, -key • Critical fix: Use 'Gabbo' as sender (only accepted value by SoundTouch API) Host:Port Parsing Enhancement: • Support -host 192.168.178.28:8090 format in addition to separate -host/-port flags • Robust parsing with IPv4, IPv6, and hostname support • Graceful fallback for invalid input • Backward compatible with existing usage Testing & Documentation: • Comprehensive unit tests for key functionality and host:port parsing • Integration tested with real SoundTouch 10 and SoundTouch 20 devices • Complete documentation in docs/KEY-CONTROLS.md and docs/HOST-PORT-PARSING.md • All tests pass, no diagnostics errors Breaking Changes: None Backward Compatibility: Fully maintained Tested with: • SoundTouch 10 (192.168.178.28:8090) ✅ • SoundTouch 20 (192.168.178.35:8090) ✅ --- cmd/soundtouch-cli/main.go | 186 ++++++++++++++++++++- cmd/soundtouch-cli/main_test.go | 246 +++++++++++++++++++++++++++ docs/HOST-PORT-PARSING.md | 211 +++++++++++++++++++++++ docs/KEY-CONTROLS.md | 256 ++++++++++++++++++++++++++++ pkg/client/client.go | 82 +++++++++ pkg/models/key.go | 96 +++++++++++ pkg/models/key_test.go | 286 ++++++++++++++++++++++++++++++++ 7 files changed, 1354 insertions(+), 9 deletions(-) create mode 100644 cmd/soundtouch-cli/main_test.go create mode 100644 docs/HOST-PORT-PARSING.md create mode 100644 docs/KEY-CONTROLS.md create mode 100644 pkg/models/key.go create mode 100644 pkg/models/key_test.go diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go index c4ef894..af46d41 100644 --- a/cmd/soundtouch-cli/main.go +++ b/cmd/soundtouch-cli/main.go @@ -5,6 +5,8 @@ import ( "flag" "fmt" "log" + "net" + "strconv" "strings" "time" @@ -14,9 +16,33 @@ import ( "github.com/user_account/bose-soundtouch/pkg/models" ) +// parseHostPort splits a host:port string into separate host and port components +// If no port is specified, returns the original host and the provided default port +func parseHostPort(hostPort string, defaultPort int) (string, int) { + // Check if host contains a port (has a colon) + if strings.Contains(hostPort, ":") { + host, portStr, err := net.SplitHostPort(hostPort) + if err != nil { + // If parsing fails, return original host and default port + return hostPort, defaultPort + } + + port, err := strconv.Atoi(portStr) + if err != nil || port < 1 || port > 65535 { + // If port parsing fails or is invalid, return host and default port + return host, defaultPort + } + + return host, port + } + + // No port specified, return original host and default port + return hostPort, defaultPort +} + func main() { var ( - host = flag.String("host", "", "SoundTouch device host/IP address") + host = flag.String("host", "", "SoundTouch device host/IP address (can include port like host:8090)") port = flag.Int("port", 8090, "SoundTouch device port") timeout = flag.Duration("timeout", 10*time.Second, "Request timeout") discover = flag.Bool("discover", false, "Discover SoundTouch devices via UPnP") @@ -27,6 +53,15 @@ func main() { name = flag.Bool("name", false, "Get device name") capabilities = flag.Bool("capabilities", false, "Get device capabilities") presets = flag.Bool("presets", false, "Get configured presets") + key = flag.String("key", "", "Send key command (PLAY, PAUSE, STOP, PREV_TRACK, NEXT_TRACK, VOLUME_UP, VOLUME_DOWN, PRESET_1-6)") + play = flag.Bool("play", false, "Send PLAY key command") + pause = flag.Bool("pause", false, "Send PAUSE key command") + stop = flag.Bool("stop", false, "Send STOP key command") + next = flag.Bool("next", false, "Send NEXT_TRACK key command") + prev = flag.Bool("prev", false, "Send PREV_TRACK key command") + volumeUp = flag.Bool("volume-up", false, "Send VOLUME_UP key command") + volumeDown = flag.Bool("volume-down", false, "Send VOLUME_DOWN key command") + preset = flag.Int("preset", 0, "Select preset (1-6)") help = flag.Bool("help", false, "Show help") ) @@ -38,11 +73,18 @@ func main() { } // If no specific action is requested, show help - if !*discover && !*discoverAll && !*info && !*nowPlaying && !*sources && !*name && !*capabilities && !*presets && *host == "" { + if !*discover && !*discoverAll && !*info && !*nowPlaying && !*sources && !*name && !*capabilities && !*presets && *key == "" && !*play && !*pause && !*stop && !*next && !*prev && !*volumeUp && !*volumeDown && *preset == 0 && *host == "" { printHelp() return } + // Parse host:port if provided + var finalHost string + var finalPort int + if *host != "" { + finalHost, finalPort = parseHostPort(*host, *port) + } + // Handle discovery if *discover || *discoverAll { if err := handleDiscovery(*discoverAll, *timeout); err != nil { @@ -56,7 +98,7 @@ func main() { if *host == "" { log.Fatal("Host is required for info command. Use -host flag or -discover to find devices.") } - if err := handleDeviceInfo(*host, *port, *timeout); err != nil { + if err := handleDeviceInfo(finalHost, finalPort, *timeout); err != nil { log.Fatalf("Failed to get device info: %v", err) } return @@ -67,7 +109,7 @@ func main() { if *host == "" { log.Fatal("Host is required for nowplaying command. Use -host flag or -discover to find devices.") } - if err := handleNowPlaying(*host, *port, *timeout); err != nil { + if err := handleNowPlaying(finalHost, finalPort, *timeout); err != nil { log.Fatalf("Failed to get now playing: %v", err) } return @@ -78,7 +120,7 @@ func main() { if *host == "" { log.Fatal("Host is required for sources command. Use -host flag or -discover to find devices.") } - if err := handleSources(*host, *port, *timeout); err != nil { + if err := handleSources(finalHost, finalPort, *timeout); err != nil { log.Fatalf("Failed to get sources: %v", err) } return @@ -89,7 +131,7 @@ func main() { if *host == "" { log.Fatal("Host is required for name command. Use -host flag or -discover to find devices.") } - if err := handleName(*host, *port, *timeout); err != nil { + if err := handleName(finalHost, finalPort, *timeout); err != nil { log.Fatalf("Failed to get device name: %v", err) } return @@ -100,7 +142,7 @@ func main() { if *host == "" { log.Fatal("Host is required for capabilities command. Use -host flag or -discover to find devices.") } - if err := handleCapabilities(*host, *port, *timeout); err != nil { + if err := handleCapabilities(finalHost, finalPort, *timeout); err != nil { log.Fatalf("Failed to get device capabilities: %v", err) } return @@ -111,11 +153,22 @@ func main() { if *host == "" { log.Fatal("Host is required for presets command. Use -host flag or -discover to find devices.") } - if err := handlePresets(*host, *port, *timeout); err != nil { + if err := handlePresets(finalHost, finalPort, *timeout); err != nil { log.Fatalf("Failed to get presets: %v", err) } return } + + // Handle key commands + if *key != "" || *play || *pause || *stop || *next || *prev || *volumeUp || *volumeDown || *preset > 0 { + if *host == "" { + log.Fatal("Host is required for key commands. Use -host flag or -discover to find devices.") + } + if err := handleKeyCommands(finalHost, finalPort, *timeout, *key, *play, *pause, *stop, *next, *prev, *volumeUp, *volumeDown, *preset); err != nil { + log.Fatalf("Failed to send key command: %v", err) + } + return + } } func printHelp() { @@ -125,7 +178,7 @@ func printHelp() { fmt.Println(" soundtouch-cli [options]") fmt.Println() fmt.Println("Options:") - fmt.Println(" -host SoundTouch device IP address") + fmt.Println(" -host SoundTouch device IP address (or host:port)") fmt.Println(" -port SoundTouch device port (default: 8090)") fmt.Println(" -timeout Request timeout (default: 10s)") fmt.Println(" -discover Discover SoundTouch devices via UPnP") @@ -136,17 +189,32 @@ func printHelp() { fmt.Println(" -name Get device name (requires -host)") fmt.Println(" -capabilities Get device capabilities (requires -host)") fmt.Println(" -presets Get configured presets (requires -host)") + fmt.Println(" -key Send key command (requires -host)") + fmt.Println(" -play Send PLAY key command (requires -host)") + fmt.Println(" -pause Send PAUSE key command (requires -host)") + fmt.Println(" -stop Send STOP key command (requires -host)") + fmt.Println(" -next Send NEXT_TRACK key command (requires -host)") + fmt.Println(" -prev Send PREV_TRACK key command (requires -host)") + fmt.Println(" -volume-up Send VOLUME_UP key command (requires -host)") + fmt.Println(" -volume-down Send VOLUME_DOWN key command (requires -host)") + fmt.Println(" -preset <1-6> Select preset (requires -host)") fmt.Println(" -help Show this help message") fmt.Println() fmt.Println("Examples:") fmt.Println(" soundtouch-cli -discover") fmt.Println(" soundtouch-cli -discover-all") fmt.Println(" soundtouch-cli -host 192.168.1.100 -info") + fmt.Println(" soundtouch-cli -host 192.168.1.100:8090 -info") fmt.Println(" soundtouch-cli -host 192.168.1.100 -nowplaying") fmt.Println(" soundtouch-cli -host 192.168.1.100 -sources") fmt.Println(" soundtouch-cli -host 192.168.1.100 -name") fmt.Println(" soundtouch-cli -host 192.168.1.100 -capabilities") fmt.Println(" soundtouch-cli -host 192.168.1.100 -presets") + fmt.Println(" soundtouch-cli -host 192.168.1.100 -play") + fmt.Println(" soundtouch-cli -host 192.168.1.100:8090 -pause") + fmt.Println(" soundtouch-cli -host 192.168.1.100 -volume-up") + fmt.Println(" soundtouch-cli -host 192.168.1.100:8090 -preset 1") + fmt.Println(" soundtouch-cli -host 192.168.1.100 -key STOP") fmt.Println(" soundtouch-cli -host 192.168.1.100 -port 8090 -info") } @@ -724,3 +792,103 @@ func handlePresets(host string, port int, timeout time.Duration) error { return nil } + +func handleKeyCommands(host string, port int, timeout time.Duration, key string, play, pause, stop, next, prev, volumeUp, volumeDown bool, preset int) error { + cfg, err := config.LoadFromEnv() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // Override config with command line arguments if provided + if timeout > 0 { + cfg.HTTPTimeout = timeout + } + + clientConfig := client.ClientConfig{ + Host: host, + Port: port, + Timeout: cfg.HTTPTimeout, + UserAgent: cfg.UserAgent, + } + + soundtouchClient := client.NewClient(clientConfig) + + // Count how many commands are requested + commandCount := 0 + var commandName string + + if key != "" { + commandCount++ + commandName = fmt.Sprintf("key %s", key) + } + if play { + commandCount++ + commandName = "PLAY" + } + if pause { + commandCount++ + commandName = "PAUSE" + } + if stop { + commandCount++ + commandName = "STOP" + } + if next { + commandCount++ + commandName = "NEXT_TRACK" + } + if prev { + commandCount++ + commandName = "PREV_TRACK" + } + if volumeUp { + commandCount++ + commandName = "VOLUME_UP" + } + if volumeDown { + commandCount++ + commandName = "VOLUME_DOWN" + } + if preset > 0 { + commandCount++ + commandName = fmt.Sprintf("PRESET_%d", preset) + } + + // Only allow one command at a time + if commandCount > 1 { + return fmt.Errorf("only one key command can be sent at a time") + } + if commandCount == 0 { + return fmt.Errorf("no key command specified") + } + + fmt.Printf("Sending %s command to %s:%d...\n", commandName, host, port) + + // Execute the appropriate command + if key != "" { + err = soundtouchClient.SendKey(strings.ToUpper(key)) + } else if play { + err = soundtouchClient.Play() + } else if pause { + err = soundtouchClient.Pause() + } else if stop { + err = soundtouchClient.Stop() + } else if next { + err = soundtouchClient.NextTrack() + } else if prev { + err = soundtouchClient.PrevTrack() + } else if volumeUp { + err = soundtouchClient.VolumeUp() + } else if volumeDown { + err = soundtouchClient.VolumeDown() + } else if preset > 0 { + err = soundtouchClient.SelectPreset(preset) + } + + if err != nil { + return fmt.Errorf("failed to send key command: %w", err) + } + + fmt.Printf("✓ %s command sent successfully\n", commandName) + return nil +} diff --git a/cmd/soundtouch-cli/main_test.go b/cmd/soundtouch-cli/main_test.go new file mode 100644 index 0000000..9587b55 --- /dev/null +++ b/cmd/soundtouch-cli/main_test.go @@ -0,0 +1,246 @@ +package main + +import ( + "testing" +) + +func TestParseHostPort(t *testing.T) { + tests := []struct { + name string + input string + defaultPort int + wantHost string + wantPort int + }{ + { + name: "IPv4 with port", + input: "192.168.1.100:8090", + defaultPort: 8080, + wantHost: "192.168.1.100", + wantPort: 8090, + }, + { + name: "IPv4 without port", + input: "192.168.1.100", + defaultPort: 8080, + wantHost: "192.168.1.100", + wantPort: 8080, + }, + { + name: "hostname with port", + input: "soundtouch.local:9090", + defaultPort: 8080, + wantHost: "soundtouch.local", + wantPort: 9090, + }, + { + name: "hostname without port", + input: "soundtouch.local", + defaultPort: 8080, + wantHost: "soundtouch.local", + wantPort: 8080, + }, + { + name: "localhost with port", + input: "localhost:3000", + defaultPort: 8080, + wantHost: "localhost", + wantPort: 3000, + }, + { + name: "IPv6 with port", + input: "[::1]:8090", + defaultPort: 8080, + wantHost: "::1", + wantPort: 8090, + }, + { + name: "IPv6 without port", + input: "::1", + defaultPort: 8080, + wantHost: "::1", + wantPort: 8080, + }, + { + name: "invalid port - non-numeric", + input: "192.168.1.100:abc", + defaultPort: 8080, + wantHost: "192.168.1.100", + wantPort: 8080, + }, + { + name: "invalid port - too high", + input: "192.168.1.100:99999", + defaultPort: 8080, + wantHost: "192.168.1.100", + wantPort: 8080, + }, + { + name: "invalid port - zero", + input: "192.168.1.100:0", + defaultPort: 8080, + wantHost: "192.168.1.100", + wantPort: 8080, + }, + { + name: "invalid port - negative", + input: "192.168.1.100:-1", + defaultPort: 8080, + wantHost: "192.168.1.100", + wantPort: 8080, + }, + { + name: "empty string", + input: "", + defaultPort: 8080, + wantHost: "", + wantPort: 8080, + }, + { + name: "just colon", + input: ":", + defaultPort: 8080, + wantHost: "", + wantPort: 8080, + }, + { + name: "multiple colons - malformed", + input: "192.168.1.100:8090:extra", + defaultPort: 8080, + wantHost: "192.168.1.100:8090:extra", + wantPort: 8080, + }, + { + name: "standard SoundTouch default", + input: "192.168.1.100:8090", + defaultPort: 8090, + wantHost: "192.168.1.100", + wantPort: 8090, + }, + { + name: "valid high port", + input: "192.168.1.100:65535", + defaultPort: 8080, + wantHost: "192.168.1.100", + wantPort: 65535, + }, + { + name: "valid low port", + input: "192.168.1.100:1", + defaultPort: 8080, + wantHost: "192.168.1.100", + wantPort: 1, + }, + { + name: "real SoundTouch device example", + input: "192.168.1.35:8090", + defaultPort: 8080, + wantHost: "192.168.1.35", + wantPort: 8090, + }, + { + name: "hostname only fallback", + input: "bose-soundtouch-20", + defaultPort: 8090, + wantHost: "bose-soundtouch-20", + wantPort: 8090, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotHost, gotPort := parseHostPort(tt.input, tt.defaultPort) + if gotHost != tt.wantHost { + t.Errorf("parseHostPort() host = %v, want %v", gotHost, tt.wantHost) + } + if gotPort != tt.wantPort { + t.Errorf("parseHostPort() port = %v, want %v", gotPort, tt.wantPort) + } + }) + } +} + +func BenchmarkParseHostPort(b *testing.B) { + testCases := []struct { + name string + input string + }{ + {"with_port", "192.168.1.100:8090"}, + {"without_port", "192.168.1.100"}, + {"hostname_with_port", "soundtouch.local:8090"}, + {"ipv6_with_port", "[::1]:8090"}, + } + + for _, tc := range testCases { + b.Run(tc.name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + parseHostPort(tc.input, 8080) + } + }) + } +} + +// Test edge cases with real-world SoundTouch scenarios +func TestParseHostPortSoundTouchScenarios(t *testing.T) { + tests := []struct { + name string + input string + defaultPort int + description string + wantHost string + wantPort int + }{ + { + name: "typical_cli_usage", + input: "192.168.1.100:8090", + defaultPort: 8090, + description: "User specifies full host:port", + wantHost: "192.168.1.100", + wantPort: 8090, + }, + { + name: "discovery_result_host_only", + input: "192.168.1.100", + defaultPort: 8090, + description: "Discovery returns IP, CLI uses default port", + wantHost: "192.168.1.100", + wantPort: 8090, + }, + { + name: "custom_port_override", + input: "192.168.1.100:9000", + defaultPort: 8090, + description: "User overrides default SoundTouch port", + wantHost: "192.168.1.100", + wantPort: 9000, + }, + { + name: "hostname_resolution", + input: "bose-kitchen.local:8090", + defaultPort: 8090, + description: "mDNS/Bonjour hostname with port", + wantHost: "bose-kitchen.local", + wantPort: 8090, + }, + { + name: "invalid_port_fallback", + input: "192.168.1.100:invalid", + defaultPort: 8090, + description: "Malformed port should fallback to default", + wantHost: "192.168.1.100", + wantPort: 8090, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotHost, gotPort := parseHostPort(tt.input, tt.defaultPort) + if gotHost != tt.wantHost { + t.Errorf("parseHostPort() host = %v, want %v (scenario: %s)", gotHost, tt.wantHost, tt.description) + } + if gotPort != tt.wantPort { + t.Errorf("parseHostPort() port = %v, want %v (scenario: %s)", gotPort, tt.wantPort, tt.description) + } + }) + } +} diff --git a/docs/HOST-PORT-PARSING.md b/docs/HOST-PORT-PARSING.md new file mode 100644 index 0000000..5d16b0b --- /dev/null +++ b/docs/HOST-PORT-PARSING.md @@ -0,0 +1,211 @@ +# Host:Port Parsing Feature + +This document describes the automatic host:port parsing functionality added to the SoundTouch CLI, which allows users to specify both host and port in a single `-host` flag. + +## Overview + +The SoundTouch CLI now supports parsing host and port combinations in the `-host` flag, making it more user-friendly and following common CLI patterns. Users can specify either just a host (using the default or `-port` flag) or a complete `host:port` combination. + +## Usage Examples + +### Basic Host:Port Format +```bash +# Specify host and port together +soundtouch-cli -host 192.168.1.100:8090 -info +soundtouch-cli -host 192.168.1.35:8090 -play +soundtouch-cli -host soundtouch.local:8090 -pause +``` + +### Traditional Separate Flags (Still Supported) +```bash +# Traditional separate host and port flags +soundtouch-cli -host 192.168.1.100 -port 8090 -info +soundtouch-cli -host 192.168.1.35 -port 8090 -play +``` + +### Precedence Rules +When both formats are used, the port specified in the host:port format takes precedence: +```bash +# Uses port 8090 from host:port, ignores -port 9999 +soundtouch-cli -host 192.168.1.100:8090 -port 9999 -info +``` + +## Supported Formats + +### IPv4 Addresses +```bash +# Standard IPv4 with port +soundtouch-cli -host 192.168.1.100:8090 -info + +# IPv4 without port (uses default 8090) +soundtouch-cli -host 192.168.1.100 -info +``` + +### Hostnames +```bash +# Hostname with port +soundtouch-cli -host soundtouch.local:8090 -info +soundtouch-cli -host bose-kitchen:9000 -play + +# Hostname without port (uses default) +soundtouch-cli -host soundtouch.local -info +``` + +### IPv6 Addresses +```bash +# IPv6 with port (requires brackets) +soundtouch-cli -host [::1]:8090 -info +soundtouch-cli -host [2001:db8::1]:8090 -play + +# IPv6 without port +soundtouch-cli -host ::1 -info +``` + +## Implementation Details + +### Parsing Function +The `parseHostPort()` function handles the parsing logic: + +```go +func parseHostPort(hostPort string, defaultPort int) (string, int) +``` + +### Parsing Rules +1. **Contains colon**: Attempts to split using `net.SplitHostPort()` +2. **Valid port**: Port must be numeric and in range 1-65535 +3. **Invalid port**: Falls back to original host and default port +4. **No colon**: Returns original input as host with default port +5. **Parse error**: Returns original input as host with default port + +### Error Handling +The parser is designed to be forgiving and always return usable values: + +- **Invalid port numbers**: Fall back to default port +- **Malformed input**: Return original input as host +- **Empty input**: Handle gracefully +- **Multiple colons**: Handled by `net.SplitHostPort()` error handling + +## Test Coverage + +### Unit Tests +Comprehensive test coverage in `cmd/soundtouch-cli/main_test.go`: + +- ✅ IPv4 addresses with and without ports +- ✅ Hostnames with and without ports +- ✅ IPv6 addresses with and without ports +- ✅ Invalid port handling +- ✅ Edge cases (empty strings, malformed input) +- ✅ Real-world SoundTouch scenarios + +### Integration Tests +Tested with real SoundTouch devices: +- ✅ SoundTouch 10 (192.168.1.100:8090) +- ✅ SoundTouch 20 (192.168.1.35:8090) + +## Benefits + +### User Experience +- **Simplified syntax**: `host:port` is more intuitive than separate flags +- **Consistent with other tools**: Follows common CLI patterns +- **Backward compatible**: Existing scripts continue to work +- **Copy-paste friendly**: Can copy host:port from discovery output + +### Development Benefits +- **Robust parsing**: Handles edge cases gracefully +- **Comprehensive tests**: Well-tested functionality +- **Clean implementation**: Uses Go standard library +- **Error resilience**: Falls back to sensible defaults + +## Examples with Real Devices + +### Discovery + Direct Usage +```bash +# Discover devices to find host:port +$ soundtouch-cli -discover +Found SoundTouch devices: + My SoundTouch Device (192.168.1.100:8090) - SoundTouch 20 + +# Use discovered host:port directly +$ soundtouch-cli -host 192.168.1.100:8090 -play +``` + +### Different Port Scenarios +```bash +# Standard SoundTouch port +soundtouch-cli -host 192.168.1.100:8090 -info + +# Custom port (if device configured differently) +soundtouch-cli -host 192.168.1.100:9000 -info + +# Default port fallback +soundtouch-cli -host 192.168.1.100 -info # Uses 8090 +``` + +### Error Scenarios +```bash +# Invalid port - uses default 8090 +soundtouch-cli -host 192.168.1.100:invalid -info + +# Out of range port - uses default 8090 +soundtouch-cli -host 192.168.1.100:99999 -info + +# Malformed input - treats as hostname +soundtouch-cli -host "malformed::input" -info +``` + +## CLI Help Output + +The help text has been updated to reflect the new functionality: + +``` +Options: + -host SoundTouch device IP address (or host:port) + -port SoundTouch device port (default: 8090) + +Examples: + soundtouch-cli -host 192.168.1.100 -info + soundtouch-cli -host 192.168.1.100:8090 -info + soundtouch-cli -host 192.168.1.100:8090 -pause + soundtouch-cli -host 192.168.1.100:8090 -preset 1 +``` + +## Technical Implementation + +### Function Signature +```go +// parseHostPort splits a host:port string into separate host and port components +// If no port is specified, returns the original host and the provided default port +func parseHostPort(hostPort string, defaultPort int) (string, int) +``` + +### Key Features +- Uses Go's `net.SplitHostPort()` for robust parsing +- Validates port range (1-65535) +- Handles IPv6 addresses correctly with brackets +- Graceful fallback for all error conditions +- Preserves original host for malformed input + +### Integration Points +The parsed values are used throughout the CLI: +- Device info commands +- Now playing queries +- Source management +- Key control commands +- All API endpoint interactions + +## Future Enhancements + +Potential improvements for the future: + +1. **URL Format Support**: Support full URLs like `http://192.168.1.100:8090` +2. **Service Discovery**: Auto-detect port via service discovery protocols +3. **Configuration File**: Save frequently used host:port combinations +4. **Environment Variables**: Support `SOUNDTOUCH_HOST` with host:port format +5. **Validation**: More sophisticated host validation (DNS lookup, ping) + +## Reference + +- **Implementation**: `cmd/soundtouch-cli/main.go` (parseHostPort function) +- **Tests**: `cmd/soundtouch-cli/main_test.go` +- **Go Documentation**: `net.SplitHostPort()` for parsing logic +- **Standards**: Follows RFC 3986 for host:port format \ No newline at end of file diff --git a/docs/KEY-CONTROLS.md b/docs/KEY-CONTROLS.md new file mode 100644 index 0000000..69865da --- /dev/null +++ b/docs/KEY-CONTROLS.md @@ -0,0 +1,256 @@ +# Key Control Implementation + +This document describes the implementation of the POST `/key` endpoint for media control commands in the Bose SoundTouch API client. + +## Overview + +The key control functionality allows sending media control commands to SoundTouch devices, including play/pause, volume adjustment, track navigation, and preset selection. + +## Implementation Files + +- `pkg/models/key.go` - XML model and constants for key commands +- `pkg/models/key_test.go` - Comprehensive tests for key functionality +- `pkg/client/client.go` - Client methods for sending key commands +- `cmd/soundtouch-cli/main.go` - CLI commands for key controls + +## API Specification + +### POST /key + +Sends a key command to the SoundTouch device. + +**Request Format:** +```xml +KEY_NAME +``` + +**Response Format:** +```xml + +/key +``` + +### Important Discovery: Sender Field + +During implementation, we discovered that the `sender` attribute is critical for successful key commands. Only specific sender values are accepted: + +- ✅ **"Gabbo"** - Works (canonical example from official documentation) +- ❌ "GoClient" - Rejected with CLIENT_XML_ERROR (1019) +- ❌ "SoundTouch app" - Rejected with CLIENT_XML_ERROR (1019) +- ❌ "" (empty) - Rejected with CLIENT_XML_ERROR (1019) + +Our implementation uses **"Gabbo"** as the default sender, which is the standard value used in official SoundTouch examples. + +## Available Key Commands + +### Media Controls +- `PLAY` - Start playback +- `PAUSE` - Pause playback +- `STOP` - Stop playback +- `PREV_TRACK` - Previous track +- `NEXT_TRACK` - Next track + +### Volume Controls +- `VOLUME_UP` - Increase volume +- `VOLUME_DOWN` - Decrease volume + +### Presets +- `PRESET_1` through `PRESET_6` - Select preset 1-6 + +## Client API + +### Basic Methods + +```go +// Send any valid key command +err := client.SendKey(models.KeyPlay) + +// Send key press (default behavior) +err := client.SendKeyPress(models.KeyPlay) + +// Send key release +err := client.SendKeyRelease(models.KeyPlay) +``` + +### Convenience Methods + +```go +// Media controls +err := client.Play() +err := client.Pause() +err := client.Stop() +err := client.NextTrack() +err := client.PrevTrack() + +// Volume controls +err := client.VolumeUp() +err := client.VolumeDown() + +// Preset selection (1-6) +err := client.SelectPreset(1) +``` + +### Key Validation + +```go +// Check if a key value is valid +isValid := models.IsValidKey("PLAY") // true +isValid := models.IsValidKey("INVALID") // false + +// Get all valid key values +allKeys := models.GetAllValidKeys() +``` + +## CLI Usage + +### Individual Key Commands + +```bash +# Media controls +soundtouch-cli -host 192.168.1.100 -play +soundtouch-cli -host 192.168.1.100 -pause +soundtouch-cli -host 192.168.1.100 -stop +soundtouch-cli -host 192.168.1.100 -next +soundtouch-cli -host 192.168.1.100 -prev + +# Volume controls +soundtouch-cli -host 192.168.1.100 -volume-up +soundtouch-cli -host 192.168.1.100 -volume-down + +# Preset selection +soundtouch-cli -host 192.168.1.100 -preset 1 +soundtouch-cli -host 192.168.1.100 -preset 6 +``` + +### Generic Key Command + +```bash +# Send any valid key using the -key flag +soundtouch-cli -host 192.168.1.100 -key PLAY +soundtouch-cli -host 192.168.1.100 -key STOP +soundtouch-cli -host 192.168.1.100 -key PRESET_3 +``` + +### Error Handling + +```bash +# Invalid key validation +$ soundtouch-cli -host 192.168.1.100 -key INVALID +Failed to send key command: invalid key value: INVALID + +# Multiple commands rejected +$ soundtouch-cli -host 192.168.1.100 -play -pause +Failed to send key command: only one key command can be sent at a time + +# Missing host +$ soundtouch-cli -play +Host is required for key commands. Use -host flag or -discover to find devices. +``` + +## Testing + +### Unit Tests + +The implementation includes comprehensive unit tests in `pkg/models/key_test.go`: + +- XML marshaling/unmarshaling +- Key validation +- Constructor functions +- Constants validation +- Benchmark tests + +Run tests: +```bash +go test ./pkg/models/... +``` + +### Integration Testing + +Tested with real SoundTouch devices: +- **SoundTouch 10** (192.168.1.100:8090) ✅ +- **SoundTouch 20** (192.168.1.35:8090) ✅ + +All key commands successfully sent and executed on both devices. + +## Code Examples + +### Basic Usage + +```go +package main + +import ( + "log" + "github.com/user_account/bose-soundtouch/pkg/client" + "github.com/user_account/bose-soundtouch/pkg/models" +) + +func main() { + // Create client + soundtouchClient := client.NewClientFromHost("192.168.1.100") + + // Play music + if err := soundtouchClient.Play(); err != nil { + log.Fatalf("Failed to play: %v", err) + } + + // Adjust volume + if err := soundtouchClient.VolumeUp(); err != nil { + log.Fatalf("Failed to increase volume: %v", err) + } + + // Select preset + if err := soundtouchClient.SelectPreset(1); err != nil { + log.Fatalf("Failed to select preset: %v", err) + } +} +``` + +### Advanced Usage with Validation + +```go +func sendKeyCommand(client *client.Client, keyValue string) error { + // Validate before sending + if !models.IsValidKey(keyValue) { + return fmt.Errorf("invalid key: %s", keyValue) + } + + return client.SendKey(keyValue) +} + +func sendAllValidKeys(client *client.Client) { + for _, key := range models.GetAllValidKeys() { + fmt.Printf("Sending key: %s\n", key) + if err := client.SendKey(key); err != nil { + log.Printf("Failed to send %s: %v", key, err) + } + time.Sleep(1 * time.Second) // Avoid overwhelming the device + } +} +``` + +## Implementation Notes + +1. **Sender Field Critical**: The `sender` attribute must be "Gabbo" for commands to be accepted +2. **XML Format**: Simple XML structure without namespaces or headers +3. **State Handling**: Both "press" and "release" states are supported +4. **Input Validation**: All key values are validated before sending to the device +5. **Error Handling**: Comprehensive error handling for invalid keys and API errors +6. **CLI Safety**: Only one key command allowed per CLI invocation to prevent conflicts + +## Future Enhancements + +Potential areas for future development: + +1. **Key Sequences**: Support for sending multiple key commands in sequence +2. **Macros**: Predefined key command sequences (e.g., "power on and play preset 1") +3. **Key Hold**: Support for key hold duration for volume changes +4. **Device State**: Check device state before sending commands +5. **Async Commands**: Non-blocking key command execution +6. **Key Mapping**: Custom key mappings for different device types + +## Reference + +- **Official API**: Based on Bose SoundTouch Web API documentation +- **Test Devices**: Validated with SoundTouch 10 and SoundTouch 20 +- **Standards**: Follows existing project patterns and conventions \ No newline at end of file diff --git a/pkg/client/client.go b/pkg/client/client.go index 4352bce..cee1812 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -126,6 +126,88 @@ func (c *Client) GetPresets() (*models.Presets, error) { return &presets, nil } +// SendKey sends a key press command to the device +func (c *Client) SendKey(keyValue string) error { + if !models.IsValidKey(keyValue) { + return fmt.Errorf("invalid key value: %s", keyValue) + } + + key := models.NewKey(keyValue) + return c.post("/key", key, nil) +} + +// SendKeyPress sends a key press command (alias for SendKey) +func (c *Client) SendKeyPress(keyValue string) error { + return c.SendKey(keyValue) +} + +// SendKeyRelease sends a key release command +func (c *Client) SendKeyRelease(keyValue string) error { + if !models.IsValidKey(keyValue) { + return fmt.Errorf("invalid key value: %s", keyValue) + } + + key := models.NewKeyRelease(keyValue) + return c.post("/key", key, nil) +} + +// Play sends a PLAY key command +func (c *Client) Play() error { + return c.SendKey(models.KeyPlay) +} + +// Pause sends a PAUSE key command +func (c *Client) Pause() error { + return c.SendKey(models.KeyPause) +} + +// Stop sends a STOP key command +func (c *Client) Stop() error { + return c.SendKey(models.KeyStop) +} + +// NextTrack sends a NEXT_TRACK key command +func (c *Client) NextTrack() error { + return c.SendKey(models.KeyNextTrack) +} + +// PrevTrack sends a PREV_TRACK key command +func (c *Client) PrevTrack() error { + return c.SendKey(models.KeyPrevTrack) +} + +// VolumeUp sends a VOLUME_UP key command +func (c *Client) VolumeUp() error { + return c.SendKey(models.KeyVolumeUp) +} + +// VolumeDown sends a VOLUME_DOWN key command +func (c *Client) VolumeDown() error { + return c.SendKey(models.KeyVolumeDown) +} + +// SelectPreset sends a preset key command (1-6) +func (c *Client) SelectPreset(presetNumber int) error { + var keyValue string + switch presetNumber { + case 1: + keyValue = models.KeyPreset1 + case 2: + keyValue = models.KeyPreset2 + case 3: + keyValue = models.KeyPreset3 + case 4: + keyValue = models.KeyPreset4 + case 5: + keyValue = models.KeyPreset5 + case 6: + keyValue = models.KeyPreset6 + default: + return fmt.Errorf("invalid preset number: %d (must be 1-6)", presetNumber) + } + return c.SendKey(keyValue) +} + // Ping checks if the device is reachable by calling /info func (c *Client) Ping() error { _, err := c.GetDeviceInfo() diff --git a/pkg/models/key.go b/pkg/models/key.go new file mode 100644 index 0000000..55ebc06 --- /dev/null +++ b/pkg/models/key.go @@ -0,0 +1,96 @@ +package models + +import "encoding/xml" + +// Key represents a key press command for the /key endpoint +type Key struct { + XMLName xml.Name `xml:"key"` + State string `xml:"state,attr"` + Sender string `xml:"sender,attr"` + Value string `xml:",chardata"` +} + +// KeyState constants for key press states +const ( + KeyStatePress = "press" + KeyStateRelease = "release" +) + +// KeyValue constants for available keys +const ( + KeyPlay = "PLAY" + KeyPause = "PAUSE" + KeyStop = "STOP" + KeyPrevTrack = "PREV_TRACK" + KeyNextTrack = "NEXT_TRACK" + KeyVolumeUp = "VOLUME_UP" + KeyVolumeDown = "VOLUME_DOWN" + KeyPreset1 = "PRESET_1" + KeyPreset2 = "PRESET_2" + KeyPreset3 = "PRESET_3" + KeyPreset4 = "PRESET_4" + KeyPreset5 = "PRESET_5" + KeyPreset6 = "PRESET_6" +) + +// NewKey creates a new key press command +func NewKey(keyValue string) *Key { + return &Key{ + State: KeyStatePress, + Sender: "Gabbo", + Value: keyValue, + } +} + +// NewKeyPress creates a new key press command (alias for NewKey) +func NewKeyPress(keyValue string) *Key { + return NewKey(keyValue) +} + +// NewKeyRelease creates a new key release command +func NewKeyRelease(keyValue string) *Key { + return &Key{ + State: KeyStateRelease, + Sender: "Gabbo", + Value: keyValue, + } +} + +// IsValidKey checks if the key value is valid +func IsValidKey(keyValue string) bool { + validKeys := map[string]bool{ + KeyPlay: true, + KeyPause: true, + KeyStop: true, + KeyPrevTrack: true, + KeyNextTrack: true, + KeyVolumeUp: true, + KeyVolumeDown: true, + KeyPreset1: true, + KeyPreset2: true, + KeyPreset3: true, + KeyPreset4: true, + KeyPreset5: true, + KeyPreset6: true, + } + return validKeys[keyValue] +} + +// GetAllValidKeys returns a slice of all valid key values +func GetAllValidKeys() []string { + return []string{ + KeyPlay, + KeyPause, + KeyStop, + KeyPrevTrack, + KeyNextTrack, + KeyVolumeUp, + KeyVolumeDown, + KeyPreset1, + KeyPreset2, + KeyPreset3, + KeyPreset4, + KeyPreset5, + KeyPreset6, + } +} diff --git a/pkg/models/key_test.go b/pkg/models/key_test.go new file mode 100644 index 0000000..3f7f760 --- /dev/null +++ b/pkg/models/key_test.go @@ -0,0 +1,286 @@ +package models + +import ( + "encoding/xml" + "testing" +) + +func TestNewKey(t *testing.T) { + key := NewKey(KeyPlay) + + if key.State != KeyStatePress { + t.Errorf("Expected state %q, got %q", KeyStatePress, key.State) + } + + if key.Sender != "Gabbo" { + t.Errorf("Expected sender %q, got %q", "Gabbo", key.Sender) + } + + if key.Value != KeyPlay { + t.Errorf("Expected value %q, got %q", KeyPlay, key.Value) + } +} + +func TestNewKeyPress(t *testing.T) { + key := NewKeyPress(KeyPause) + + if key.State != KeyStatePress { + t.Errorf("Expected state %q, got %q", KeyStatePress, key.State) + } + + if key.Value != KeyPause { + t.Errorf("Expected value %q, got %q", KeyPause, key.Value) + } +} + +func TestNewKeyRelease(t *testing.T) { + key := NewKeyRelease(KeyStop) + + if key.State != KeyStateRelease { + t.Errorf("Expected state %q, got %q", KeyStateRelease, key.State) + } + + if key.Sender != "Gabbo" { + t.Errorf("Expected sender %q, got %q", "Gabbo", key.Sender) + } + + if key.Value != KeyStop { + t.Errorf("Expected value %q, got %q", KeyStop, key.Value) + } +} + +func TestKeyXMLMarshal(t *testing.T) { + tests := []struct { + name string + key *Key + expectedXML string + }{ + { + name: "PLAY key press", + key: NewKey(KeyPlay), + expectedXML: `PLAY`, + }, + { + name: "PAUSE key press", + key: NewKey(KeyPause), + expectedXML: `PAUSE`, + }, + { + name: "STOP key release", + key: NewKeyRelease(KeyStop), + expectedXML: `STOP`, + }, + { + name: "VOLUME_UP key press", + key: NewKey(KeyVolumeUp), + expectedXML: `VOLUME_UP`, + }, + { + name: "PRESET_1 key press", + key: NewKey(KeyPreset1), + expectedXML: `PRESET_1`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + xmlData, err := xml.Marshal(tt.key) + if err != nil { + t.Fatalf("Failed to marshal XML: %v", err) + } + + if string(xmlData) != tt.expectedXML { + t.Errorf("Expected XML %q, got %q", tt.expectedXML, string(xmlData)) + } + }) + } +} + +func TestKeyXMLUnmarshal(t *testing.T) { + tests := []struct { + name string + xmlData string + want Key + }{ + { + name: "PLAY key press", + xmlData: `PLAY`, + want: Key{ + State: KeyStatePress, + Sender: "Gabbo", + Value: KeyPlay, + }, + }, + { + name: "PAUSE key release", + xmlData: `PAUSE`, + want: Key{ + State: KeyStateRelease, + Sender: "TestSender", + Value: KeyPause, + }, + }, + { + name: "VOLUME_DOWN key press", + xmlData: `VOLUME_DOWN`, + want: Key{ + State: KeyStatePress, + Sender: "WebApp", + Value: KeyVolumeDown, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var key Key + err := xml.Unmarshal([]byte(tt.xmlData), &key) + if err != nil { + t.Fatalf("Failed to unmarshal XML: %v", err) + } + + if key.State != tt.want.State { + t.Errorf("Expected state %q, got %q", tt.want.State, key.State) + } + + if key.Sender != tt.want.Sender { + t.Errorf("Expected sender %q, got %q", tt.want.Sender, key.Sender) + } + + if key.Value != tt.want.Value { + t.Errorf("Expected value %q, got %q", tt.want.Value, key.Value) + } + }) + } +} + +func TestIsValidKey(t *testing.T) { + validKeys := []string{ + KeyPlay, KeyPause, KeyStop, + KeyPrevTrack, KeyNextTrack, + KeyVolumeUp, KeyVolumeDown, + KeyPreset1, KeyPreset2, KeyPreset3, + KeyPreset4, KeyPreset5, KeyPreset6, + } + + invalidKeys := []string{ + "INVALID_KEY", "play", "PAUSE_BUTTON", + "PRESET_7", "PRESET_0", "", "VOLUME", + } + + for _, key := range validKeys { + t.Run("valid_"+key, func(t *testing.T) { + if !IsValidKey(key) { + t.Errorf("Expected %q to be valid", key) + } + }) + } + + for _, key := range invalidKeys { + t.Run("invalid_"+key, func(t *testing.T) { + if IsValidKey(key) { + t.Errorf("Expected %q to be invalid", key) + } + }) + } +} + +func TestGetAllValidKeys(t *testing.T) { + keys := GetAllValidKeys() + + expectedKeys := []string{ + KeyPlay, KeyPause, KeyStop, + KeyPrevTrack, KeyNextTrack, + KeyVolumeUp, KeyVolumeDown, + KeyPreset1, KeyPreset2, KeyPreset3, + KeyPreset4, KeyPreset5, KeyPreset6, + } + + if len(keys) != len(expectedKeys) { + t.Errorf("Expected %d keys, got %d", len(expectedKeys), len(keys)) + } + + keyMap := make(map[string]bool) + for _, key := range keys { + keyMap[key] = true + } + + for _, expectedKey := range expectedKeys { + if !keyMap[expectedKey] { + t.Errorf("Expected key %q not found in result", expectedKey) + } + } + + // Verify all returned keys are valid + for _, key := range keys { + if !IsValidKey(key) { + t.Errorf("GetAllValidKeys returned invalid key: %q", key) + } + } +} + +func TestKeyConstants(t *testing.T) { + // Test that all key constants are defined and non-empty + keyTests := []struct { + name string + value string + }{ + {"KeyPlay", KeyPlay}, + {"KeyPause", KeyPause}, + {"KeyStop", KeyStop}, + {"KeyPrevTrack", KeyPrevTrack}, + {"KeyNextTrack", KeyNextTrack}, + {"KeyVolumeUp", KeyVolumeUp}, + {"KeyVolumeDown", KeyVolumeDown}, + {"KeyPreset1", KeyPreset1}, + {"KeyPreset2", KeyPreset2}, + {"KeyPreset3", KeyPreset3}, + {"KeyPreset4", KeyPreset4}, + {"KeyPreset5", KeyPreset5}, + {"KeyPreset6", KeyPreset6}, + } + + for _, tt := range keyTests { + t.Run(tt.name, func(t *testing.T) { + if tt.value == "" { + t.Errorf("Expected %s to be non-empty", tt.name) + } + }) + } +} + +func TestKeyStateConstants(t *testing.T) { + if KeyStatePress == "" { + t.Error("KeyStatePress should not be empty") + } + + if KeyStateRelease == "" { + t.Error("KeyStateRelease should not be empty") + } + + if KeyStatePress == KeyStateRelease { + t.Error("KeyStatePress and KeyStateRelease should be different") + } +} + +// Benchmark tests +func BenchmarkNewKey(b *testing.B) { + for i := 0; i < b.N; i++ { + NewKey(KeyPlay) + } +} + +func BenchmarkKeyXMLMarshal(b *testing.B) { + key := NewKey(KeyPlay) + b.ResetTimer() + + for i := 0; i < b.N; i++ { + xml.Marshal(key) + } +} + +func BenchmarkIsValidKey(b *testing.B) { + for i := 0; i < b.N; i++ { + IsValidKey(KeyPlay) + } +}