diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go index af46d41..c1fb390 100644 --- a/cmd/soundtouch-cli/main.go +++ b/cmd/soundtouch-cli/main.go @@ -62,6 +62,10 @@ func main() { 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)") + volume = flag.Bool("volume", false, "Get current volume level") + setVolume = flag.Int("set-volume", -1, "Set volume level (0-100)") + incVolume = flag.Int("inc-volume", 0, "Increase volume by amount (1-10, default: 2)") + decVolume = flag.Int("dec-volume", 0, "Decrease volume by amount (1-10, default: 2)") help = flag.Bool("help", false, "Show help") ) @@ -73,7 +77,7 @@ func main() { } // If no specific action is requested, show help - if !*discover && !*discoverAll && !*info && !*nowPlaying && !*sources && !*name && !*capabilities && !*presets && *key == "" && !*play && !*pause && !*stop && !*next && !*prev && !*volumeUp && !*volumeDown && *preset == 0 && *host == "" { + if !*discover && !*discoverAll && !*info && !*nowPlaying && !*sources && !*name && !*capabilities && !*presets && *key == "" && !*play && !*pause && !*stop && !*next && !*prev && !*volumeUp && !*volumeDown && *preset == 0 && !*volume && *setVolume == -1 && *incVolume == 0 && *decVolume == 0 && *host == "" { printHelp() return } @@ -169,6 +173,17 @@ func main() { } return } + + // Handle volume commands + if *volume || *setVolume != -1 || *incVolume > 0 || *decVolume > 0 { + if *host == "" { + log.Fatal("Host is required for volume commands. Use -host flag or -discover to find devices.") + } + if err := handleVolumeCommands(finalHost, finalPort, *timeout, *volume, *setVolume, *incVolume, *decVolume); err != nil { + log.Fatalf("Failed to execute volume command: %v", err) + } + return + } } func printHelp() { @@ -198,6 +213,10 @@ func printHelp() { 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(" -volume Get current volume level (requires -host)") + fmt.Println(" -set-volume <0-100> Set volume level (requires -host)") + fmt.Println(" -inc-volume <1-10> Increase volume by amount (requires -host, default: 2)") + fmt.Println(" -dec-volume <1-10> Decrease volume by amount (requires -host, default: 2)") fmt.Println(" -help Show this help message") fmt.Println() fmt.Println("Examples:") @@ -215,6 +234,10 @@ func printHelp() { 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 -volume") + fmt.Println(" soundtouch-cli -host 192.168.1.100:8090 -set-volume 25") + fmt.Println(" soundtouch-cli -host 192.168.1.100 -inc-volume 2") + fmt.Println(" soundtouch-cli -host 192.168.1.100 -dec-volume 3") fmt.Println(" soundtouch-cli -host 192.168.1.100 -port 8090 -info") } @@ -892,3 +915,107 @@ func handleKeyCommands(host string, port int, timeout time.Duration, key string, fmt.Printf("✓ %s command sent successfully\n", commandName) return nil } + +func handleVolumeCommands(host string, port int, timeout time.Duration, getVolume bool, setVolume, incVolume, decVolume 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) + + // Handle get volume + if getVolume { + fmt.Printf("Getting current volume from %s:%d...\n", host, port) + volume, err := soundtouchClient.GetVolume() + if err != nil { + return fmt.Errorf("failed to get volume: %w", err) + } + + fmt.Printf("Current Volume:\n") + fmt.Printf(" Device ID: %s\n", volume.DeviceID) + fmt.Printf(" Current Level: %d (%s)\n", volume.GetLevel(), models.GetVolumeLevelName(volume.GetLevel())) + fmt.Printf(" Target Level: %d\n", volume.GetTargetLevel()) + fmt.Printf(" Muted: %v\n", volume.IsMuted()) + if !volume.IsVolumeSync() { + fmt.Printf(" Note: Volume is adjusting (target: %d, actual: %d)\n", volume.GetTargetLevel(), volume.GetLevel()) + } + return nil + } + + // Handle set volume + if setVolume != -1 { + if setVolume > 30 { + fmt.Printf("⚠️ Warning: Setting volume to %d (this is quite loud!)\n", setVolume) + fmt.Printf("Proceeding in 2 seconds... Press Ctrl+C to cancel\n") + time.Sleep(2 * time.Second) + } + + fmt.Printf("Setting volume to %d on %s:%d...\n", setVolume, host, port) + err := soundtouchClient.SetVolume(setVolume) + if err != nil { + return fmt.Errorf("failed to set volume: %w", err) + } + + // Get updated volume + volume, err := soundtouchClient.GetVolume() + if err != nil { + fmt.Printf("✓ Volume set successfully\n") + } else { + fmt.Printf("✓ Volume set to %d (%s)\n", volume.GetLevel(), models.GetVolumeLevelName(volume.GetLevel())) + } + return nil + } + + // Handle volume increase (with safety limits) + if incVolume > 0 { + if incVolume > 10 { + incVolume = 10 // Safety limit + } + if incVolume == 0 { + incVolume = 2 // Default increment + } + + fmt.Printf("Increasing volume by %d on %s:%d...\n", incVolume, host, port) + volume, err := soundtouchClient.IncreaseVolume(incVolume) + if err != nil { + return fmt.Errorf("failed to increase volume: %w", err) + } + + fmt.Printf("✓ Volume increased to %d (%s)\n", volume.GetLevel(), models.GetVolumeLevelName(volume.GetLevel())) + return nil + } + + // Handle volume decrease + if decVolume > 0 { + if decVolume > 20 { + decVolume = 20 // Safety limit for decrease + } + if decVolume == 0 { + decVolume = 2 // Default decrement + } + + fmt.Printf("Decreasing volume by %d on %s:%d...\n", decVolume, host, port) + volume, err := soundtouchClient.DecreaseVolume(decVolume) + if err != nil { + return fmt.Errorf("failed to decrease volume: %w", err) + } + + fmt.Printf("✓ Volume decreased to %d (%s)\n", volume.GetLevel(), models.GetVolumeLevelName(volume.GetLevel())) + return nil + } + + return fmt.Errorf("no volume command specified") +} diff --git a/docs/KEY-CONTROLS.md b/docs/KEY-CONTROLS.md index 69865da..89e68bc 100644 --- a/docs/KEY-CONTROLS.md +++ b/docs/KEY-CONTROLS.md @@ -20,8 +20,10 @@ The key control functionality allows sending media control commands to SoundTouc Sends a key command to the SoundTouch device. **Request Format:** +According to the API documentation, proper key simulation requires sending both press and release states: ```xml KEY_NAME +KEY_NAME ``` **Response Format:** @@ -62,14 +64,17 @@ Our implementation uses **"Gabbo"** as the default sender, which is the standard ### Basic Methods ```go -// Send any valid key command +// Send complete key command (press + release - recommended) err := client.SendKey(models.KeyPlay) -// Send key press (default behavior) +// Send key press and release (alias for SendKey) err := client.SendKeyPress(models.KeyPlay) -// Send key release -err := client.SendKeyRelease(models.KeyPlay) +// Send only key press state (advanced usage) +err := client.SendKeyPressOnly(models.KeyPlay) + +// Send only key release state (advanced usage) +err := client.SendKeyReleaseOnly(models.KeyPlay) ``` ### Convenience Methods @@ -215,12 +220,13 @@ func sendKeyCommand(client *client.Client, keyValue string) error { return fmt.Errorf("invalid key: %s", keyValue) } + // SendKey automatically sends both press and release states return client.SendKey(keyValue) } func sendAllValidKeys(client *client.Client) { for _, key := range models.GetAllValidKeys() { - fmt.Printf("Sending key: %s\n", key) + fmt.Printf("Sending key: %s (press+release)\n", key) if err := client.SendKey(key); err != nil { log.Printf("Failed to send %s: %v", key, err) } @@ -231,12 +237,13 @@ func sendAllValidKeys(client *client.Client) { ## 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 +1. **Press + Release Pattern**: Following API documentation, `SendKey()` sends both press and release states for proper key simulation +2. **Sender Field Critical**: The `sender` attribute must be "Gabbo" for commands to be accepted +3. **XML Format**: Simple XML structure without namespaces or headers +4. **State Handling**: Both "press" and "release" states are supported, with complete press+release cycle as default +5. **Input Validation**: All key values are validated before sending to the device +6. **Error Handling**: Comprehensive error handling for invalid keys and API errors +7. **CLI Safety**: Only one key command allowed per CLI invocation to prevent conflicts ## Future Enhancements diff --git a/docs/VOLUME-CONTROLS.md b/docs/VOLUME-CONTROLS.md new file mode 100644 index 0000000..bb96995 --- /dev/null +++ b/docs/VOLUME-CONTROLS.md @@ -0,0 +1,374 @@ +# Volume Control Implementation + +This document describes the implementation of the GET/POST `/volume` endpoints for volume management in the Bose SoundTouch API client. + +## Overview + +The volume control functionality allows getting current volume levels and setting new volume levels on SoundTouch devices. It provides both direct volume setting and incremental adjustments with safety features. + +## Implementation Files + +- `pkg/models/volume.go` - XML model and validation for volume endpoints +- `pkg/models/volume_test.go` - Comprehensive tests for volume functionality +- `pkg/client/client.go` - Client methods for volume control +- `cmd/soundtouch-cli/main.go` - CLI commands for volume management + +## API Specification + +### GET /volume + +Retrieves the current volume level and mute status from the device. + +**Response Format:** +```xml + + 50 + 50 + false + +``` + +### POST /volume + +Sets the volume level on the device. + +**Request Format:** +```xml +50 +``` + +**Response Format:** +```xml + +/volume +``` + +## Volume Levels + +### Valid Range +- **Minimum**: 0 (mute/silent) +- **Maximum**: 100 (loudest) +- **Validation**: All values must be within 0-100 range + +### Volume Categories +- **Mute**: 0 +- **Very Quiet**: 1-10 +- **Quiet**: 11-25 +- **Medium**: 26-50 +- **High**: 51-75 +- **Loud**: 76-100 + +## Client API + +### Basic Volume Operations + +```go +// Get current volume information +volume, err := client.GetVolume() +if err != nil { + log.Printf("Failed to get volume: %v", err) +} + +fmt.Printf("Current volume: %d (%s)\n", volume.GetLevel(), volume.GetVolumeString()) +fmt.Printf("Target volume: %d\n", volume.GetTargetLevel()) +fmt.Printf("Muted: %v\n", volume.IsMuted()) +``` + +### Set Volume + +```go +// Set specific volume level (0-100) +err := client.SetVolume(50) + +// Set volume with automatic clamping for invalid values +err := client.SetVolumeSafe(150) // Will be clamped to 100 +``` + +### Incremental Volume Control + +```go +// Increase volume by specified amount +newVolume, err := client.IncreaseVolume(5) +if err != nil { + log.Printf("Failed to increase volume: %v", err) +} else { + fmt.Printf("Volume increased to: %d\n", newVolume.GetLevel()) +} + +// Decrease volume by specified amount +newVolume, err := client.DecreaseVolume(3) +if err != nil { + log.Printf("Failed to decrease volume: %v", err) +} else { + fmt.Printf("Volume decreased to: %d\n", newVolume.GetLevel()) +} +``` + +### Volume Information Methods + +```go +volume, _ := client.GetVolume() + +// Get current actual volume level +level := volume.GetLevel() + +// Get target volume level +targetLevel := volume.GetTargetLevel() + +// Check if device is muted +isMuted := volume.IsMuted() + +// Check if volume is synchronized (target == actual) +isSync := volume.IsVolumeSync() + +// Get formatted volume string +volumeStr := volume.GetVolumeString() // "Muted" or "50" +``` + +### Volume Validation + +```go +// Validate volume level +if models.ValidateVolumeLevel(75) { + fmt.Println("Volume level is valid") +} + +// Clamp volume to valid range +safeLevel := models.ClampVolumeLevel(150) // Returns 100 + +// Get descriptive name for volume level +name := models.GetVolumeLevelName(25) // Returns "Quiet" +``` + +## CLI Usage + +### Get Current Volume + +```bash +# Get current volume information +soundtouch-cli -host 192.168.1.100:8090 -volume +``` + +**Output:** +``` +Current Volume: + Device ID: A81B6A536A98 + Current Level: 50 (Medium) + Target Level: 50 + Muted: false +``` + +### Set Specific Volume Level + +```bash +# Set volume to specific level (0-100) +soundtouch-cli -host 192.168.1.100:8090 -set-volume 25 +soundtouch-cli -host 192.168.1.100:8090 -set-volume 0 # Mute +``` + +**Safety Features:** +- Volumes above 30 show a warning and 2-second delay +- Invalid volumes are rejected with error message + +### Incremental Volume Control + +```bash +# Increase volume by amount (1-10, default: 2) +soundtouch-cli -host 192.168.1.100:8090 -inc-volume 3 +soundtouch-cli -host 192.168.1.100:8090 -inc-volume # Uses default: 2 + +# Decrease volume by amount (1-20, default: 2) +soundtouch-cli -host 192.168.1.100:8090 -dec-volume 5 +soundtouch-cli -host 192.168.1.100:8090 -dec-volume # Uses default: 2 +``` + +### CLI Safety Features + +1. **Volume Warnings**: High volume settings (>30) show warnings +2. **Increment Limits**: Volume increases limited to 10 per command +3. **Decrement Limits**: Volume decreases limited to 20 per command +4. **Automatic Clamping**: All values automatically clamped to 0-100 range +5. **Error Handling**: Clear error messages for invalid operations + +## Testing + +### Unit Tests + +The implementation includes comprehensive unit tests in `pkg/models/volume_test.go`: + +- XML marshaling/unmarshaling for requests and responses +- Volume validation and clamping functions +- Volume level categorization +- Helper method functionality +- Constants validation +- Benchmark tests for performance + +Run tests: +```bash +go test ./pkg/models/volume* +``` + +### Integration Testing + +Tested with real SoundTouch devices: +- **SoundTouch 10** (192.168.1.100:8090) ✅ +- **SoundTouch 20** (192.168.1.35:8090) ✅ + +All volume operations successfully tested on both devices. + +## Code Examples + +### Basic Volume Control + +```go +package main + +import ( + "fmt" + "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") + + // Get current volume + volume, err := soundtouchClient.GetVolume() + if err != nil { + log.Fatalf("Failed to get volume: %v", err) + } + + fmt.Printf("Current volume: %d (%s)\n", + volume.GetLevel(), + models.GetVolumeLevelName(volume.GetLevel())) + + // Set to comfortable listening level + if err := soundtouchClient.SetVolume(35); err != nil { + log.Fatalf("Failed to set volume: %v", err) + } + + fmt.Println("Volume set to comfortable level") +} +``` + +### Volume Monitoring + +```go +func monitorVolume(client *client.Client) { + for { + volume, err := client.GetVolume() + if err != nil { + log.Printf("Error getting volume: %v", err) + continue + } + + fmt.Printf("Volume: %d", volume.GetLevel()) + + if volume.IsMuted() { + fmt.Print(" (MUTED)") + } + + if !volume.IsVolumeSync() { + fmt.Printf(" -> %d (adjusting)", volume.GetTargetLevel()) + } + + fmt.Println() + time.Sleep(2 * time.Second) + } +} +``` + +### Safe Volume Management + +```go +func safeVolumeControl(client *client.Client, newLevel int) error { + // Get current volume first + currentVolume, err := client.GetVolume() + if err != nil { + return fmt.Errorf("failed to get current volume: %w", err) + } + + // Don't allow large jumps in volume + currentLevel := currentVolume.GetLevel() + if abs(newLevel - currentLevel) > 20 { + return fmt.Errorf("volume change too large: %d -> %d", currentLevel, newLevel) + } + + // Validate and clamp + if !models.ValidateVolumeLevel(newLevel) { + newLevel = models.ClampVolumeLevel(newLevel) + fmt.Printf("Volume clamped to safe range: %d\n", newLevel) + } + + // Set volume + return client.SetVolume(newLevel) +} + +func abs(x int) int { + if x < 0 { + return -x + } + return x +} +``` + +## Implementation Notes + +1. **Target vs Actual Volume**: The API returns both target and actual volume levels. During volume changes, these may differ temporarily. + +2. **Volume Synchronization**: Use `IsVolumeSync()` to check if the device has finished adjusting to the target volume. + +3. **Mute Handling**: Mute is a separate boolean field, not just volume level 0. + +4. **Safety Features**: The CLI implementation includes several safety features to prevent accidental loud volume settings. + +5. **Incremental Control**: The `IncreaseVolume()` and `DecreaseVolume()` methods return the updated volume level for immediate feedback. + +6. **Error Handling**: All volume operations include comprehensive error handling with descriptive messages. + +## Relationship to Key Controls + +Volume can be controlled in two ways: + +### Via Volume API (Precise) +```go +client.SetVolume(50) // Set exact level +client.IncreaseVolume(5) // Increase by exact amount +``` + +### Via Key Commands (Step-based) +```go +client.VolumeUp() // Single step up +client.VolumeDown() // Single step down +client.SendKey(models.KeyVolumeUp) // Same as VolumeUp() +``` + +**Note**: Key commands now properly send both press and release states as per API documentation. + +## Known Issues and Workarounds + +1. **Stuck Volume Keys**: If volume appears to be continuously adjusting, there may be a stuck key press. Send the same key command to ensure proper press+release cycle. + +2. **External Volume Control**: Some sources (like Spotify apps) may override volume settings. Check the source and consider switching sources if needed. + +3. **Volume Jumps**: If volume jumps unexpectedly during increment/decrement operations, check for external volume control interference. + +## Future Enhancements + +Potential areas for future development: + +1. **Volume Profiles**: Predefined volume profiles (quiet, normal, party) +2. **Time-based Volume**: Automatic volume adjustment based on time of day +3. **Source-specific Volume**: Remember volume levels per audio source +4. **Volume Limits**: Configurable maximum volume limits for safety +5. **Volume Fade**: Gradual volume transitions for smooth experience +6. **Volume Monitoring**: Real-time volume change notifications via WebSocket + +## 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 +- **Safety**: Implements multiple safety features for user protection \ No newline at end of file diff --git a/pkg/client/client.go b/pkg/client/client.go index cee1812..8bf3515 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -126,21 +126,44 @@ func (c *Client) GetPresets() (*models.Presets, error) { return &presets, nil } -// SendKey sends a key press command to the device +// SendKey sends a key press command to the device (press followed by release) func (c *Client) SendKey(keyValue string) error { if !models.IsValidKey(keyValue) { return fmt.Errorf("invalid key value: %s", keyValue) } + // Send press state + keyPress := models.NewKey(keyValue) + err := c.post("/key", keyPress, nil) + if err != nil { + return fmt.Errorf("failed to send key press: %w", err) + } + + // Send release state + keyRelease := models.NewKeyRelease(keyValue) + err = c.post("/key", keyRelease, nil) + if err != nil { + return fmt.Errorf("failed to send key release: %w", err) + } + + return nil +} + +// SendKeyPress sends a key press command (alias for SendKey - sends press+release) +func (c *Client) SendKeyPress(keyValue string) error { + return c.SendKey(keyValue) +} + +// SendKeyPressOnly sends only the key press state (without release) +func (c *Client) SendKeyPressOnly(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) { @@ -151,6 +174,11 @@ func (c *Client) SendKeyRelease(keyValue string) error { return c.post("/key", key, nil) } +// SendKeyReleaseOnly sends only the key release state (alias for SendKeyRelease) +func (c *Client) SendKeyReleaseOnly(keyValue string) error { + return c.SendKeyRelease(keyValue) +} + // Play sends a PLAY key command func (c *Client) Play() error { return c.SendKey(models.KeyPlay) @@ -208,6 +236,66 @@ func (c *Client) SelectPreset(presetNumber int) error { return c.SendKey(keyValue) } +// GetVolume retrieves the current volume level from the /volume endpoint +func (c *Client) GetVolume() (*models.Volume, error) { + var volume models.Volume + err := c.get("/volume", &volume) + if err != nil { + return nil, fmt.Errorf("failed to get volume: %w", err) + } + return &volume, nil +} + +// SetVolume sets the volume level using the /volume endpoint +func (c *Client) SetVolume(level int) error { + if !models.ValidateVolumeLevel(level) { + return fmt.Errorf("invalid volume level: %d (must be 0-100)", level) + } + + volumeReq := models.NewVolumeRequest(level) + return c.post("/volume", volumeReq, nil) +} + +// SetVolumeSafe sets volume with validation and clamping +func (c *Client) SetVolumeSafe(level int) error { + clampedLevel := models.ClampVolumeLevel(level) + return c.SetVolume(clampedLevel) +} + +// IncreaseVolume increases volume by the specified amount (with safety limits) +func (c *Client) IncreaseVolume(amount int) (*models.Volume, error) { + currentVolume, err := c.GetVolume() + if err != nil { + return nil, fmt.Errorf("failed to get current volume: %w", err) + } + + newLevel := models.ClampVolumeLevel(currentVolume.GetLevel() + amount) + err = c.SetVolume(newLevel) + if err != nil { + return nil, fmt.Errorf("failed to set volume: %w", err) + } + + // Return updated volume + return c.GetVolume() +} + +// DecreaseVolume decreases volume by the specified amount (with safety limits) +func (c *Client) DecreaseVolume(amount int) (*models.Volume, error) { + currentVolume, err := c.GetVolume() + if err != nil { + return nil, fmt.Errorf("failed to get current volume: %w", err) + } + + newLevel := models.ClampVolumeLevel(currentVolume.GetLevel() - amount) + err = c.SetVolume(newLevel) + if err != nil { + return nil, fmt.Errorf("failed to set volume: %w", err) + } + + // Return updated volume + return c.GetVolume() +} + // 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 index 55ebc06..895b337 100644 --- a/pkg/models/key.go +++ b/pkg/models/key.go @@ -34,6 +34,7 @@ const ( ) // NewKey creates a new key press command +// Note: For proper key simulation, use client.SendKey() which sends both press and release func NewKey(keyValue string) *Key { return &Key{ State: KeyStatePress, @@ -43,11 +44,13 @@ func NewKey(keyValue string) *Key { } // NewKeyPress creates a new key press command (alias for NewKey) +// Note: This creates only the press state. For complete key simulation, use client.SendKey() func NewKeyPress(keyValue string) *Key { return NewKey(keyValue) } // NewKeyRelease creates a new key release command +// Note: This creates only the release state. For complete key simulation, use client.SendKey() func NewKeyRelease(keyValue string) *Key { return &Key{ State: KeyStateRelease, diff --git a/pkg/models/key_test.go b/pkg/models/key_test.go index 3f7f760..d990376 100644 --- a/pkg/models/key_test.go +++ b/pkg/models/key_test.go @@ -284,3 +284,52 @@ func BenchmarkIsValidKey(b *testing.B) { IsValidKey(KeyPlay) } } + +// Test that demonstrates the press+release pattern from API documentation +func TestKeyPressReleasePattern(t *testing.T) { + // According to API docs, we should send press followed by release + keyValue := KeyPlay + + // Create press command + keyPress := NewKey(keyValue) + if keyPress.State != KeyStatePress { + t.Errorf("Expected press state, got %s", keyPress.State) + } + if keyPress.Value != keyValue { + t.Errorf("Expected key value %s, got %s", keyValue, keyPress.Value) + } + if keyPress.Sender != "Gabbo" { + t.Errorf("Expected sender 'Gabbo', got %s", keyPress.Sender) + } + + // Create release command + keyRelease := NewKeyRelease(keyValue) + if keyRelease.State != KeyStateRelease { + t.Errorf("Expected release state, got %s", keyRelease.State) + } + if keyRelease.Value != keyValue { + t.Errorf("Expected key value %s, got %s", keyValue, keyRelease.Value) + } + if keyRelease.Sender != "Gabbo" { + t.Errorf("Expected sender 'Gabbo', got %s", keyRelease.Sender) + } + + // Test XML marshaling for both + pressXML, err := xml.Marshal(keyPress) + if err != nil { + t.Fatalf("Failed to marshal press XML: %v", err) + } + expectedPressXML := `PLAY` + if string(pressXML) != expectedPressXML { + t.Errorf("Press XML: got %s, want %s", string(pressXML), expectedPressXML) + } + + releaseXML, err := xml.Marshal(keyRelease) + if err != nil { + t.Fatalf("Failed to marshal release XML: %v", err) + } + expectedReleaseXML := `PLAY` + if string(releaseXML) != expectedReleaseXML { + t.Errorf("Release XML: got %s, want %s", string(releaseXML), expectedReleaseXML) + } +} diff --git a/pkg/models/volume.go b/pkg/models/volume.go new file mode 100644 index 0000000..9ceb678 --- /dev/null +++ b/pkg/models/volume.go @@ -0,0 +1,102 @@ +package models + +import ( + "encoding/xml" + "fmt" +) + +// Volume represents the response from GET /volume endpoint +type Volume struct { + XMLName xml.Name `xml:"volume"` + DeviceID string `xml:"deviceID,attr"` + TargetVolume int `xml:"targetvolume"` + ActualVolume int `xml:"actualvolume"` + MuteEnabled bool `xml:"muteenabled"` +} + +// VolumeRequest represents the request for POST /volume endpoint +type VolumeRequest struct { + XMLName xml.Name `xml:"volume"` + Value int `xml:",chardata"` +} + +// NewVolumeRequest creates a new volume set request +func NewVolumeRequest(volume int) *VolumeRequest { + return &VolumeRequest{ + Value: volume, + } +} + +// GetLevel returns the current actual volume level +func (v *Volume) GetLevel() int { + return v.ActualVolume +} + +// GetTargetLevel returns the target volume level +func (v *Volume) GetTargetLevel() int { + return v.TargetVolume +} + +// IsMuted returns whether the device is muted +func (v *Volume) IsMuted() bool { + return v.MuteEnabled +} + +// IsVolumeSync returns true if target and actual volumes match +func (v *Volume) IsVolumeSync() bool { + return v.TargetVolume == v.ActualVolume +} + +// GetVolumeString returns a formatted string representation +func (v *Volume) GetVolumeString() string { + if v.IsMuted() { + return "Muted" + } + return fmt.Sprintf("%d", v.ActualVolume) +} + +// ValidateVolumeLevel checks if a volume level is valid (0-100) +func ValidateVolumeLevel(level int) bool { + return level >= 0 && level <= 100 +} + +// ClampVolumeLevel ensures a volume level is within valid range +func ClampVolumeLevel(level int) int { + if level < 0 { + return 0 + } + if level > 100 { + return 100 + } + return level +} + +// Volume level constants +const ( + VolumeMin = 0 + VolumeMax = 100 + VolumeMute = 0 + VolumeQuiet = 10 + VolumeLow = 25 + VolumeMedium = 50 + VolumeHigh = 75 + VolumeLoud = 100 +) + +// GetVolumeLevelName returns a descriptive name for volume levels +func GetVolumeLevelName(level int) string { + switch { + case level == 0: + return "Mute" + case level <= 10: + return "Very Quiet" + case level <= 25: + return "Quiet" + case level <= 50: + return "Medium" + case level <= 75: + return "High" + default: + return "Loud" + } +} diff --git a/pkg/models/volume_test.go b/pkg/models/volume_test.go new file mode 100644 index 0000000..0a91d2b --- /dev/null +++ b/pkg/models/volume_test.go @@ -0,0 +1,504 @@ +package models + +import ( + "encoding/xml" + "fmt" + "testing" +) + +func TestNewVolumeRequest(t *testing.T) { + tests := []struct { + name string + volume int + want int + }{ + { + name: "zero volume", + volume: 0, + want: 0, + }, + { + name: "medium volume", + volume: 50, + want: 50, + }, + { + name: "max volume", + volume: 100, + want: 100, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := NewVolumeRequest(tt.volume) + if req.Value != tt.want { + t.Errorf("NewVolumeRequest() value = %d, want %d", req.Value, tt.want) + } + }) + } +} + +func TestVolumeRequestXMLMarshal(t *testing.T) { + tests := []struct { + name string + volume int + expectedXML string + }{ + { + name: "zero volume", + volume: 0, + expectedXML: `0`, + }, + { + name: "medium volume", + volume: 50, + expectedXML: `50`, + }, + { + name: "max volume", + volume: 100, + expectedXML: `100`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := NewVolumeRequest(tt.volume) + xmlData, err := xml.Marshal(req) + 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 TestVolumeXMLUnmarshal(t *testing.T) { + tests := []struct { + name string + xmlData string + want Volume + }{ + { + name: "normal volume response", + xmlData: `5050false`, + want: Volume{ + DeviceID: "12345", + TargetVolume: 50, + ActualVolume: 50, + MuteEnabled: false, + }, + }, + { + name: "muted volume response", + xmlData: `00true`, + want: Volume{ + DeviceID: "67890", + TargetVolume: 0, + ActualVolume: 0, + MuteEnabled: true, + }, + }, + { + name: "volume adjusting", + xmlData: `7570false`, + want: Volume{ + DeviceID: "54321", + TargetVolume: 75, + ActualVolume: 70, + MuteEnabled: false, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var volume Volume + err := xml.Unmarshal([]byte(tt.xmlData), &volume) + if err != nil { + t.Fatalf("Failed to unmarshal XML: %v", err) + } + + if volume.DeviceID != tt.want.DeviceID { + t.Errorf("DeviceID = %q, want %q", volume.DeviceID, tt.want.DeviceID) + } + if volume.TargetVolume != tt.want.TargetVolume { + t.Errorf("TargetVolume = %d, want %d", volume.TargetVolume, tt.want.TargetVolume) + } + if volume.ActualVolume != tt.want.ActualVolume { + t.Errorf("ActualVolume = %d, want %d", volume.ActualVolume, tt.want.ActualVolume) + } + if volume.MuteEnabled != tt.want.MuteEnabled { + t.Errorf("MuteEnabled = %v, want %v", volume.MuteEnabled, tt.want.MuteEnabled) + } + }) + } +} + +func TestVolumeGetLevel(t *testing.T) { + volume := Volume{ActualVolume: 75} + if got := volume.GetLevel(); got != 75 { + t.Errorf("GetLevel() = %d, want 75", got) + } +} + +func TestVolumeGetTargetLevel(t *testing.T) { + volume := Volume{TargetVolume: 60} + if got := volume.GetTargetLevel(); got != 60 { + t.Errorf("GetTargetLevel() = %d, want 60", got) + } +} + +func TestVolumeIsMuted(t *testing.T) { + tests := []struct { + name string + muteEnabled bool + want bool + }{ + { + name: "muted", + muteEnabled: true, + want: true, + }, + { + name: "not muted", + muteEnabled: false, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + volume := Volume{MuteEnabled: tt.muteEnabled} + if got := volume.IsMuted(); got != tt.want { + t.Errorf("IsMuted() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestVolumeIsVolumeSync(t *testing.T) { + tests := []struct { + name string + targetVolume int + actualVolume int + want bool + }{ + { + name: "synchronized", + targetVolume: 50, + actualVolume: 50, + want: true, + }, + { + name: "not synchronized", + targetVolume: 75, + actualVolume: 70, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + volume := Volume{ + TargetVolume: tt.targetVolume, + ActualVolume: tt.actualVolume, + } + if got := volume.IsVolumeSync(); got != tt.want { + t.Errorf("IsVolumeSync() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestVolumeGetVolumeString(t *testing.T) { + tests := []struct { + name string + volume Volume + expectedStr string + }{ + { + name: "muted", + volume: Volume{ActualVolume: 0, MuteEnabled: true}, + expectedStr: "Muted", + }, + { + name: "unmuted with volume", + volume: Volume{ActualVolume: 75, MuteEnabled: false}, + expectedStr: "75", + }, + { + name: "zero volume but not muted", + volume: Volume{ActualVolume: 0, MuteEnabled: false}, + expectedStr: "0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.volume.GetVolumeString(); got != tt.expectedStr { + t.Errorf("GetVolumeString() = %q, want %q", got, tt.expectedStr) + } + }) + } +} + +func TestValidateVolumeLevel(t *testing.T) { + tests := []struct { + name string + level int + want bool + }{ + { + name: "valid min", + level: 0, + want: true, + }, + { + name: "valid max", + level: 100, + want: true, + }, + { + name: "valid middle", + level: 50, + want: true, + }, + { + name: "invalid negative", + level: -1, + want: false, + }, + { + name: "invalid too high", + level: 101, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ValidateVolumeLevel(tt.level); got != tt.want { + t.Errorf("ValidateVolumeLevel(%d) = %v, want %v", tt.level, got, tt.want) + } + }) + } +} + +func TestClampVolumeLevel(t *testing.T) { + tests := []struct { + name string + level int + want int + }{ + { + name: "within range", + level: 50, + want: 50, + }, + { + name: "below min", + level: -10, + want: 0, + }, + { + name: "above max", + level: 150, + want: 100, + }, + { + name: "at min boundary", + level: 0, + want: 0, + }, + { + name: "at max boundary", + level: 100, + want: 100, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ClampVolumeLevel(tt.level); got != tt.want { + t.Errorf("ClampVolumeLevel(%d) = %d, want %d", tt.level, got, tt.want) + } + }) + } +} + +func TestGetVolumeLevelName(t *testing.T) { + tests := []struct { + name string + level int + want string + }{ + { + name: "mute", + level: 0, + want: "Mute", + }, + { + name: "very quiet", + level: 5, + want: "Very Quiet", + }, + { + name: "quiet boundary", + level: 10, + want: "Very Quiet", + }, + { + name: "quiet", + level: 20, + want: "Quiet", + }, + { + name: "quiet boundary", + level: 25, + want: "Quiet", + }, + { + name: "medium", + level: 40, + want: "Medium", + }, + { + name: "medium boundary", + level: 50, + want: "Medium", + }, + { + name: "high", + level: 65, + want: "High", + }, + { + name: "high boundary", + level: 75, + want: "High", + }, + { + name: "loud", + level: 90, + want: "Loud", + }, + { + name: "max loud", + level: 100, + want: "Loud", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := GetVolumeLevelName(tt.level); got != tt.want { + t.Errorf("GetVolumeLevelName(%d) = %q, want %q", tt.level, got, tt.want) + } + }) + } +} + +func TestVolumeConstants(t *testing.T) { + tests := []struct { + name string + constant int + expected int + }{ + {"VolumeMin", VolumeMin, 0}, + {"VolumeMax", VolumeMax, 100}, + {"VolumeMute", VolumeMute, 0}, + {"VolumeQuiet", VolumeQuiet, 10}, + {"VolumeLow", VolumeLow, 25}, + {"VolumeMedium", VolumeMedium, 50}, + {"VolumeHigh", VolumeHigh, 75}, + {"VolumeLoud", VolumeLoud, 100}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.constant != tt.expected { + t.Errorf("%s = %d, want %d", tt.name, tt.constant, tt.expected) + } + }) + } +} + +// Benchmark tests +func BenchmarkNewVolumeRequest(b *testing.B) { + for i := 0; i < b.N; i++ { + NewVolumeRequest(50) + } +} + +func BenchmarkVolumeXMLMarshal(b *testing.B) { + req := NewVolumeRequest(50) + b.ResetTimer() + + for i := 0; i < b.N; i++ { + xml.Marshal(req) + } +} + +func BenchmarkValidateVolumeLevel(b *testing.B) { + for i := 0; i < b.N; i++ { + ValidateVolumeLevel(50) + } +} + +func BenchmarkClampVolumeLevel(b *testing.B) { + for i := 0; i < b.N; i++ { + ClampVolumeLevel(150) + } +} + +func BenchmarkGetVolumeLevelName(b *testing.B) { + for i := 0; i < b.N; i++ { + GetVolumeLevelName(50) + } +} + +// Example tests +func ExampleValidateVolumeLevel() { + valid := ValidateVolumeLevel(50) + invalid := ValidateVolumeLevel(150) + + fmt.Printf("Volume 50 is valid: %v\n", valid) + fmt.Printf("Volume 150 is valid: %v\n", invalid) + + // Output: + // Volume 50 is valid: true + // Volume 150 is valid: false +} + +func ExampleClampVolumeLevel() { + clamped1 := ClampVolumeLevel(150) + clamped2 := ClampVolumeLevel(-10) + clamped3 := ClampVolumeLevel(50) + + fmt.Printf("150 clamped: %d\n", clamped1) + fmt.Printf("-10 clamped: %d\n", clamped2) + fmt.Printf("50 clamped: %d\n", clamped3) + + // Output: + // 150 clamped: 100 + // -10 clamped: 0 + // 50 clamped: 50 +} + +func ExampleGetVolumeLevelName() { + fmt.Printf("Volume 0: %s\n", GetVolumeLevelName(0)) + fmt.Printf("Volume 25: %s\n", GetVolumeLevelName(25)) + fmt.Printf("Volume 50: %s\n", GetVolumeLevelName(50)) + fmt.Printf("Volume 100: %s\n", GetVolumeLevelName(100)) + + // Output: + // Volume 0: Mute + // Volume 25: Quiet + // Volume 50: Medium + // Volume 100: Loud +}