Files
Bose-SoundTouch/pkg/client/client.go
T
Tobias Gesellchen b4e6ce7042 feat: implement GET/POST /volume endpoints with press+release key pattern
Volume Control Implementation:
• Complete GET/POST /volume endpoint implementation with XML models
• Volume model with validation, clamping, and safety features
• Client methods: GetVolume(), SetVolume(), IncreaseVolume(), DecreaseVolume()
• CLI commands: -volume, -set-volume, -inc-volume, -dec-volume with safety limits
• Comprehensive volume level categorization and helper methods

Key Controls Enhancement:
• Fix press+release pattern: SendKey() now sends both press and release states
• Follows API documentation requirement for proper key simulation
• Add SendKeyPressOnly() and SendKeyReleaseOnly() for advanced usage
• Update documentation to reflect press+release behavior
• Add test for press+release pattern validation

Safety Features:
• Volume warnings for levels >30 with 2-second delay
• Increment/decrement limits (10 up, 20 down per command)
• Automatic volume clamping to 0-100 range
• Clear volume level descriptions (Mute, Quiet, Medium, High, Loud)

Testing & Documentation:
• Comprehensive volume control tests (30+ test cases)
• Complete documentation in docs/VOLUME-CONTROLS.md
• Updated key controls documentation for press+release pattern
• Real device testing with both SoundTouch 10 and 20
• All tests pass, no diagnostics errors

Real Device Integration:
• Fixed volume key press issues through proper press+release cycle
• Tested volume API endpoints with actual devices
• Safe volume levels maintained during testing

Breaking Changes: None
Backward Compatibility: Fully maintained

Production Ready:
 Volume control endpoints (GET/POST /volume)
 Enhanced key controls with proper press+release pattern
 Comprehensive safety features for volume management
 Real device validation and testing
2026-01-08 23:56:17 +01:00

408 lines
11 KiB
Go

package client
import (
"bytes"
"encoding/xml"
"fmt"
"io"
"net/http"
"time"
"github.com/user_account/bose-soundtouch/pkg/models"
)
// Client represents a SoundTouch API client
type Client struct {
baseURL string
httpClient *http.Client
timeout time.Duration
userAgent string
}
// ClientConfig holds configuration for the SoundTouch client
type ClientConfig struct {
Host string
Port int
Timeout time.Duration
UserAgent string
}
// DefaultConfig returns a default client configuration
func DefaultConfig() ClientConfig {
return ClientConfig{
Host: "localhost",
Port: 8090,
Timeout: 10 * time.Second,
UserAgent: "Bose-SoundTouch-Go-Client/1.0",
}
}
// NewClient creates a new SoundTouch API client
func NewClient(config ClientConfig) *Client {
if config.Timeout == 0 {
config.Timeout = 10 * time.Second
}
if config.UserAgent == "" {
config.UserAgent = "Bose-SoundTouch-Go-Client/1.0"
}
if config.Port == 0 {
config.Port = 8090
}
return &Client{
baseURL: fmt.Sprintf("http://%s:%d", config.Host, config.Port),
httpClient: &http.Client{
Timeout: config.Timeout,
},
timeout: config.Timeout,
userAgent: config.UserAgent,
}
}
// NewClientFromHost creates a new client with just a host address
func NewClientFromHost(host string) *Client {
config := DefaultConfig()
config.Host = host
return NewClient(config)
}
// GetDeviceInfo retrieves device information from the /info endpoint
func (c *Client) GetDeviceInfo() (*models.DeviceInfo, error) {
var deviceInfo models.DeviceInfo
err := c.get("/info", &deviceInfo)
if err != nil {
return nil, fmt.Errorf("failed to get device info: %w", err)
}
return &deviceInfo, nil
}
// GetNowPlaying retrieves current playback information from the /now_playing endpoint
func (c *Client) GetNowPlaying() (*models.NowPlaying, error) {
var nowPlaying models.NowPlaying
err := c.get("/now_playing", &nowPlaying)
if err != nil {
return nil, fmt.Errorf("failed to get now playing: %w", err)
}
return &nowPlaying, nil
}
// GetSources retrieves available audio sources from the /sources endpoint
func (c *Client) GetSources() (*models.Sources, error) {
var sources models.Sources
err := c.get("/sources", &sources)
if err != nil {
return nil, fmt.Errorf("failed to get sources: %w", err)
}
return &sources, nil
}
// GetName retrieves the device name from the /name endpoint
func (c *Client) GetName() (*models.Name, error) {
var name models.Name
err := c.get("/name", &name)
if err != nil {
return nil, fmt.Errorf("failed to get device name: %w", err)
}
return &name, nil
}
// GetCapabilities retrieves device capabilities from the /capabilities endpoint
func (c *Client) GetCapabilities() (*models.Capabilities, error) {
var capabilities models.Capabilities
err := c.get("/capabilities", &capabilities)
if err != nil {
return nil, fmt.Errorf("failed to get device capabilities: %w", err)
}
return &capabilities, nil
}
// GetPresets retrieves configured presets from the /presets endpoint
func (c *Client) GetPresets() (*models.Presets, error) {
var presets models.Presets
err := c.get("/presets", &presets)
if err != nil {
return nil, fmt.Errorf("failed to get presets: %w", err)
}
return &presets, nil
}
// 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)
}
// 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)
}
// 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)
}
// 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)
}
// 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()
return err
}
// BaseURL returns the base URL for this client
func (c *Client) BaseURL() string {
return c.baseURL
}
// Host returns the host for this client
func (c *Client) Host() string {
return c.baseURL
}
// get performs a GET request and unmarshals the XML response
func (c *Client) get(endpoint string, result interface{}) error {
url := c.baseURL + endpoint
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Accept", "application/xml")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
// Parse the actual response first
if err := xml.Unmarshal(body, result); err != nil {
// Check if it might be an API error response instead
var apiError models.APIError
if xmlErr := xml.Unmarshal(body, &apiError); xmlErr == nil && apiError.Message != "" {
return &apiError
}
return fmt.Errorf("failed to unmarshal XML response: %w", err)
}
return nil
}
// post performs a POST request with XML body
func (c *Client) post(endpoint string, payload interface{}, result interface{}) error {
url := c.baseURL + endpoint
var body io.Reader
if payload != nil {
xmlData, err := xml.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal XML request: %w", err)
}
body = bytes.NewReader(xmlData)
}
req, err := http.NewRequest("POST", url, body)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Content-Type", "application/xml")
req.Header.Set("Accept", "application/xml")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
responseBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(responseBody))
}
if result != nil {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
// Parse the actual response first
if err := xml.Unmarshal(responseBody, result); err != nil {
// Check if it might be an API error response instead
var apiError models.APIError
if xmlErr := xml.Unmarshal(responseBody, &apiError); xmlErr == nil && apiError.Message != "" {
return &apiError
}
return fmt.Errorf("failed to unmarshal XML response: %w", err)
}
}
return nil
}