Files
Bose-SoundTouch/pkg/client/client.go
T
Tobias Gesellchen de2ff3550f Implement /name, /capabilities, and /presets informational endpoints
## New Endpoints

### GET /name 
- Simple device name retrieval with XML parsing
- Helper methods for name validation and display
- Real device name integration with anonymization

### GET /capabilities 
- Comprehensive device capabilities detection
- Complex XML structure with nested network, DSP, and system configurations
- Smart categorization: System Features, Audio Features, Network Features
- Capability-specific helper methods (HasLRStereoCapability, HasDualModeNetwork, etc.)
- Extended capabilities parsing with URLs and metadata

### GET /presets 
- Complete preset management with timestamps and metadata
- Spotify playlist integration with anonymized account information
- Smart filtering: by source, used/empty slots, most recent, oldest presets
- Comprehensive analysis: preset summaries with source breakdowns
- Time-based operations: creation/update timestamps with formatted display

## Device Introspection Features

### Capability Detection
- System capabilities: Light Switch, Clock Display, BCO Reset, Power Saving
- Audio capabilities: L/R Stereo support, DSP Mono/Stereo availability
- Network capabilities: Dual Mode, WSAPI Proxy, Hosted WiFi Configuration
- Extended capabilities: Custom endpoint discovery with URL mapping

### Preset Analysis
- Usage pattern analysis (used vs empty slots)
- Source distribution (Spotify, TuneIn, etc.)
- Temporal analysis (most recent, oldest presets)
- Content metadata extraction (artwork URLs, display names)

## Enhanced CLI Tool

### New Commands
- Added -name command with simple device identification
- Added -capabilities command with categorized feature display
- Added -presets command with comprehensive preset analysis
- Enhanced help system with all new command examples

### Rich Output Formatting
- Capability categorization with bullet-point display
- Preset timeline with creation/update timestamps
- Smart metadata display (artwork, source accounts, content types)
- Device-specific feature highlighting (different capabilities per device)

## Real Device Integration

### Multi-Device Testing
- Device 192.168.178.28: SoundTouch 10 with Light Switch, Clock Display, Hosted WiFi
- Device 192.168.178.35: SoundTouch 20 with L/R Stereo, Dual Mode networking
- Verified capability differences between device models
- Real preset data with anonymized Spotify account information

### Edge Case Handling
- Non-responsive endpoints (/trackInfo timeout handling)
- Empty preset configurations
- Missing capability sections
- Device-specific feature variations

## Quality & Testing

### Comprehensive Test Coverage
- 15+ unit tests for XML models with real device response patterns
- Client integration tests with mock HTTP servers
- Edge case validation (empty names, missing capabilities, no presets)
- Timestamp parsing and validation with Unix epoch conversion

### Production-Ready Features
- Type-safe XML unmarshaling with custom validation
- Robust error handling for network and parsing failures
- Privacy protection with anonymized real device data
- Documentation updates with real-world usage examples

## API Coverage Progress

 Complete Information Endpoints:
- GET /info - Device information
- GET /name - Device name
- GET /capabilities - Device capabilities
- GET /presets - Configured presets
- GET /now_playing - Current playback status
- GET /sources - Available audio sources

🔄 Next Phase - Control Endpoints:
- POST /key - Media controls
- GET/POST /volume - Volume management
- WebSocket / - Real-time events

Features:
 Comprehensive device introspection and capability detection
 Smart preset management with timeline analysis
 Multi-device support with hardware-specific feature detection
 Production-ready error handling and data validation
 Rich CLI interface with categorized output formatting
 Real device integration with privacy-protected test data
2026-01-08 23:32:18 +01:00

238 lines
6.1 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
}
// 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
}