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
+}