mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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
408 lines
11 KiB
Go
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
|
|
}
|