diff --git a/docs/API-COOKBOOK.md b/docs/API-COOKBOOK.md new file mode 100644 index 0000000..e9ea3cc --- /dev/null +++ b/docs/API-COOKBOOK.md @@ -0,0 +1,1037 @@ +# SoundTouch API Cookbook + +**Real-world patterns, recipes, and best practices for the SoundTouch Go client** + +This cookbook provides practical solutions to common SoundTouch integration challenges. Each recipe includes working code, error handling, and production considerations. + +## šŸ“‹ **Table of Contents** + +- [Device Management](#device-management) +- [Playback Control](#playback-control) +- [Volume & Audio](#volume--audio) +- [Real-time Monitoring](#real-time-monitoring) +- [Multiroom Coordination](#multiroom-coordination) +- [Error Handling](#error-handling) +- [Performance Optimization](#performance-optimization) +- [Production Patterns](#production-patterns) + +--- + +## šŸ–„ļø **Device Management** + +### Recipe: Robust Device Discovery + +**Problem**: Reliably find all SoundTouch devices on the network with fallback options. + +```go +package main + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/user_account/bose-soundtouch/pkg/client" + "github.com/user_account/bose-soundtouch/pkg/discovery" +) + +func discoverAllDevices(timeout time.Duration) ([]*client.Client, error) { + discoverer := discovery.NewDiscoverer(discovery.Config{ + Timeout: timeout, + }) + + fmt.Printf("šŸ” Discovering SoundTouch devices (timeout: %v)...\n", timeout) + + devices, err := discoverer.DiscoverDevices() + if err != nil { + return nil, fmt.Errorf("discovery failed: %w", err) + } + + if len(devices) == 0 { + return nil, fmt.Errorf("no devices found on network") + } + + // Create clients for each device + var clients []*client.Client + for _, device := range devices { + config := client.ClientConfig{ + Host: device.Host, + Port: device.Port, + Timeout: 5 * time.Second, + } + + c := client.NewClient(config) + + // Verify connectivity + if _, err := c.Ping(); err != nil { + log.Printf("āš ļø Device at %s:%d not responding: %v", device.Host, device.Port, err) + continue + } + + clients = append(clients, c) + } + + fmt.Printf("āœ… Found %d responsive device(s)\n", len(clients)) + return clients, nil +} + +// Usage with fallback to known IPs +func getDevicesWithFallback() []*client.Client { + // Try discovery first + clients, err := discoverAllDevices(10 * time.Second) + if err == nil && len(clients) > 0 { + return clients + } + + log.Printf("Discovery failed: %v, trying known IPs...", err) + + // Fallback to known IP addresses + knownIPs := []string{"192.168.1.100", "192.168.1.101", "192.168.1.102"} + + var clients []*client.Client + for _, ip := range knownIPs { + c := client.NewClientFromHost(ip) + if _, err := c.Ping(); err == nil { + clients = append(clients, c) + log.Printf("āœ… Connected to device at %s", ip) + } + } + + return clients +} +``` + +### Recipe: Device Health Monitoring + +**Problem**: Monitor device connectivity and automatically reconnect. + +```go +type DeviceMonitor struct { + client *client.Client + deviceName string + healthy bool + lastSeen time.Time + retryCount int + maxRetries int + checkInterval time.Duration + callbacks DeviceCallbacks +} + +type DeviceCallbacks struct { + OnHealthy func(deviceName string) + OnUnhealthy func(deviceName string, err error) + OnReconnect func(deviceName string) +} + +func NewDeviceMonitor(c *client.Client, name string) *DeviceMonitor { + return &DeviceMonitor{ + client: c, + deviceName: name, + maxRetries: 3, + checkInterval: 30 * time.Second, + } +} + +func (dm *DeviceMonitor) Start(ctx context.Context) { + ticker := time.NewTicker(dm.checkInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + dm.checkHealth() + } + } +} + +func (dm *DeviceMonitor) checkHealth() { + err := dm.client.Ping() + + if err == nil { + if !dm.healthy { + dm.healthy = true + dm.retryCount = 0 + dm.lastSeen = time.Now() + if dm.callbacks.OnHealthy != nil { + dm.callbacks.OnHealthy(dm.deviceName) + } + if dm.callbacks.OnReconnect != nil && dm.retryCount > 0 { + dm.callbacks.OnReconnect(dm.deviceName) + } + } + dm.lastSeen = time.Now() + return + } + + // Device is unhealthy + dm.healthy = false + dm.retryCount++ + + if dm.callbacks.OnUnhealthy != nil { + dm.callbacks.OnUnhealthy(dm.deviceName, err) + } + + log.Printf("āš ļø Device %s unhealthy (attempt %d/%d): %v", + dm.deviceName, dm.retryCount, dm.maxRetries, err) +} + +func (dm *DeviceMonitor) IsHealthy() bool { + return dm.healthy && time.Since(dm.lastSeen) < 2*dm.checkInterval +} +``` + +--- + +## šŸŽµ **Playback Control** + +### Recipe: Smart Play/Pause Toggle + +**Problem**: Implement intelligent play/pause that works regardless of current state. + +```go +func smartPlayPause(c *client.Client) error { + // Get current status + nowPlaying, err := c.GetNowPlaying() + if err != nil { + return fmt.Errorf("failed to get playback status: %w", err) + } + + switch nowPlaying.PlayStatus { + case "PLAY_STATE": + fmt.Println("āøļø Pausing playback...") + return c.Pause() + + case "PAUSE_STATE", "STOP_STATE": + fmt.Println("ā–¶ļø Resuming playback...") + return c.Play() + + case "BUFFERING_STATE": + fmt.Println("šŸ“” Device is buffering, waiting...") + // Wait a bit and try again + time.Sleep(2 * time.Second) + return smartPlayPause(c) + + default: + fmt.Printf("šŸ¤” Unknown play status: %s, trying play...\n", nowPlaying.PlayStatus) + return c.Play() + } +} +``` + +### Recipe: Playlist Navigation with Validation + +**Problem**: Navigate playlists safely with boundary checking and retry logic. + +```go +type PlaylistNavigator struct { + client *client.Client + maxRetries int + retryDelay time.Duration +} + +func NewPlaylistNavigator(c *client.Client) *PlaylistNavigator { + return &PlaylistNavigator{ + client: c, + maxRetries: 3, + retryDelay: time.Second, + } +} + +func (pn *PlaylistNavigator) NextTrack() error { + return pn.executeWithRetry(func() error { + return pn.client.NextTrack() + }, "next track") +} + +func (pn *PlaylistNavigator) PreviousTrack() error { + return pn.executeWithRetry(func() error { + return pn.client.PrevTrack() + }, "previous track") +} + +func (pn *PlaylistNavigator) executeWithRetry(operation func() error, operationName string) error { + var lastErr error + + for attempt := 1; attempt <= pn.maxRetries; attempt++ { + lastErr = operation() + if lastErr == nil { + return nil + } + + log.Printf("āš ļø %s attempt %d failed: %v", operationName, attempt, lastErr) + + if attempt < pn.maxRetries { + time.Sleep(pn.retryDelay) + } + } + + return fmt.Errorf("%s failed after %d attempts: %w", operationName, pn.maxRetries, lastErr) +} + +func (pn *PlaylistNavigator) GetTrackInfo() (string, error) { + nowPlaying, err := pn.client.GetNowPlaying() + if err != nil { + return "", err + } + + info := "Unknown Track" + if nowPlaying.Track != "" && nowPlaying.Artist != "" { + info = fmt.Sprintf("%s - %s", nowPlaying.Artist, nowPlaying.Track) + } else if nowPlaying.Track != "" { + info = nowPlaying.Track + } else if nowPlaying.StationName != "" { + info = nowPlaying.StationName + } + + return info, nil +} +``` + +--- + +## šŸ”Š **Volume & Audio** + +### Recipe: Gradual Volume Transitions + +**Problem**: Smoothly transition volume levels without jarring jumps. + +```go +type VolumeController struct { + client *client.Client + stepSize int + stepDelay time.Duration + maxVolume int + warningLevel int +} + +func NewVolumeController(c *client.Client) *VolumeController { + return &VolumeController{ + client: c, + stepSize: 2, + stepDelay: 100 * time.Millisecond, + maxVolume: 80, // Safety limit + warningLevel: 70, + } +} + +func (vc *VolumeController) FadeIn(targetVolume int, duration time.Duration) error { + if targetVolume > vc.maxVolume { + return fmt.Errorf("target volume %d exceeds safety limit %d", targetVolume, vc.maxVolume) + } + + current, err := vc.getCurrentVolume() + if err != nil { + return err + } + + return vc.transitionVolume(current, targetVolume, duration) +} + +func (vc *VolumeController) FadeOut(duration time.Duration) error { + current, err := vc.getCurrentVolume() + if err != nil { + return err + } + + return vc.transitionVolume(current, 0, duration) +} + +func (vc *VolumeController) transitionVolume(from, to int, duration time.Duration) error { + if from == to { + return nil + } + + steps := abs(to - from) + if steps == 0 { + return nil + } + + stepDuration := duration / time.Duration(steps) + direction := 1 + if to < from { + direction = -1 + } + + current := from + for current != to { + if direction > 0 && current < to { + current = min(current+vc.stepSize, to) + } else if direction < 0 && current > to { + current = max(current-vc.stepSize, to) + } + + if err := vc.client.SetVolume(current); err != nil { + return fmt.Errorf("failed to set volume to %d: %w", current, err) + } + + if current >= vc.warningLevel { + fmt.Printf("āš ļø High volume warning: %d\n", current) + } + + if current != to { + time.Sleep(stepDuration) + } + } + + return nil +} + +func (vc *VolumeController) getCurrentVolume() (int, error) { + volume, err := vc.client.GetVolume() + if err != nil { + return 0, err + } + return volume.TargetVolume, nil +} + +// Helper functions +func abs(x int) int { + if x < 0 { return -x } + return x +} + +func min(a, b int) int { + if a < b { return a } + return b +} + +func max(a, b int) int { + if a > b { return a } + return b +} +``` + +### Recipe: Audio Profile Management + +**Problem**: Save and restore audio settings for different scenarios. + +```go +type AudioProfile struct { + Name string `json:"name"` + Volume int `json:"volume"` + Bass int `json:"bass"` + Balance int `json:"balance"` + Source string `json:"source,omitempty"` +} + +type ProfileManager struct { + client *client.Client + profiles map[string]AudioProfile +} + +func NewProfileManager(c *client.Client) *ProfileManager { + return &ProfileManager{ + client: c, + profiles: make(map[string]AudioProfile), + } +} + +func (pm *ProfileManager) CreateProfile(name string) error { + // Get current settings + volume, err := pm.client.GetVolume() + if err != nil { + return fmt.Errorf("failed to get volume: %w", err) + } + + bass, err := pm.client.GetBass() + if err != nil { + return fmt.Errorf("failed to get bass: %w", err) + } + + balance, err := pm.client.GetBalance() + if err != nil { + // Balance might not be supported, use default + balance = &models.Balance{TargetBalance: 0} + } + + nowPlaying, err := pm.client.GetNowPlaying() + source := "" + if err == nil { + source = nowPlaying.Source + } + + profile := AudioProfile{ + Name: name, + Volume: volume.TargetVolume, + Bass: bass.TargetBass, + Balance: balance.TargetBalance, + Source: source, + } + + pm.profiles[name] = profile + fmt.Printf("āœ… Profile '%s' saved: Vol=%d, Bass=%d, Balance=%d\n", + name, profile.Volume, profile.Bass, profile.Balance) + + return nil +} + +func (pm *ProfileManager) ApplyProfile(name string) error { + profile, exists := pm.profiles[name] + if !exists { + return fmt.Errorf("profile '%s' not found", name) + } + + fmt.Printf("šŸŽ›ļø Applying profile '%s'...\n", name) + + // Apply settings in order + if err := pm.client.SetVolume(profile.Volume); err != nil { + return fmt.Errorf("failed to set volume: %w", err) + } + + if err := pm.client.SetBassSafe(profile.Bass); err != nil { + log.Printf("Warning: failed to set bass: %v", err) + } + + if err := pm.client.SetBalanceSafe(profile.Balance); err != nil { + log.Printf("Warning: failed to set balance: %v", err) + } + + if profile.Source != "" { + if err := pm.client.SelectSource(profile.Source, ""); err != nil { + log.Printf("Warning: failed to select source %s: %v", profile.Source, err) + } + } + + fmt.Printf("āœ… Profile '%s' applied successfully\n", name) + return nil +} + +func (pm *ProfileManager) ListProfiles() []string { + var names []string + for name := range pm.profiles { + names = append(names, name) + } + return names +} + +// Predefined profiles +func (pm *ProfileManager) CreateDefaultProfiles() { + // Movie profile + pm.profiles["movie"] = AudioProfile{ + Name: "movie", + Volume: 60, + Bass: 3, + Balance: 0, + } + + // Music profile + pm.profiles["music"] = AudioProfile{ + Name: "music", + Volume: 50, + Bass: 1, + Balance: 0, + } + + // Night profile (low volume) + pm.profiles["night"] = AudioProfile{ + Name: "night", + Volume: 20, + Bass: -1, + Balance: 0, + } + + fmt.Println("āœ… Default profiles created: movie, music, night") +} +``` + +--- + +## šŸ“” **Real-time Monitoring** + +### Recipe: Event-Driven State Manager + +**Problem**: Keep track of device state changes and trigger actions based on events. + +```go +type StateManager struct { + client *client.Client + wsClient *client.WebSocketClient + state DeviceState + mutex sync.RWMutex + subscribers []StateSubscriber +} + +type DeviceState struct { + Volume int + Muted bool + PlayStatus string + CurrentTrack string + CurrentArtist string + Source string + LastUpdate time.Time +} + +type StateSubscriber interface { + OnStateChange(oldState, newState DeviceState) +} + +func NewStateManager(c *client.Client) *StateManager { + sm := &StateManager{ + client: c, + state: DeviceState{LastUpdate: time.Now()}, + } + + // Initialize WebSocket client + sm.wsClient = c.NewWebSocketClient(nil) + sm.setupEventHandlers() + + return sm +} + +func (sm *StateManager) setupEventHandlers() { + sm.wsClient.OnVolumeUpdated(func(event *models.VolumeUpdatedEvent) { + sm.mutex.Lock() + oldState := sm.state + sm.state.Volume = event.Volume.TargetVolume + sm.state.Muted = event.Volume.Muted + sm.state.LastUpdate = time.Now() + newState := sm.state + sm.mutex.Unlock() + + sm.notifySubscribers(oldState, newState) + }) + + sm.wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) { + sm.mutex.Lock() + oldState := sm.state + sm.state.PlayStatus = event.NowPlaying.PlayStatus + sm.state.CurrentTrack = event.NowPlaying.Track + sm.state.CurrentArtist = event.NowPlaying.Artist + sm.state.Source = event.NowPlaying.Source + sm.state.LastUpdate = time.Now() + newState := sm.state + sm.mutex.Unlock() + + sm.notifySubscribers(oldState, newState) + }) +} + +func (sm *StateManager) Start() error { + return sm.wsClient.Connect() +} + +func (sm *StateManager) Stop() error { + return sm.wsClient.Disconnect() +} + +func (sm *StateManager) GetState() DeviceState { + sm.mutex.RLock() + defer sm.mutex.RUnlock() + return sm.state +} + +func (sm *StateManager) Subscribe(subscriber StateSubscriber) { + sm.mutex.Lock() + defer sm.mutex.Unlock() + sm.subscribers = append(sm.subscribers, subscriber) +} + +func (sm *StateManager) notifySubscribers(oldState, newState DeviceState) { + sm.mutex.RLock() + subscribers := make([]StateSubscriber, len(sm.subscribers)) + copy(subscribers, sm.subscribers) + sm.mutex.RUnlock() + + for _, subscriber := range subscribers { + go subscriber.OnStateChange(oldState, newState) + } +} + +// Example subscriber: Auto-pause when volume muted +type AutoPauseSubscriber struct { + client *client.Client +} + +func (aps *AutoPauseSubscriber) OnStateChange(oldState, newState DeviceState) { + if !oldState.Muted && newState.Muted && newState.PlayStatus == "PLAY_STATE" { + log.Println("šŸ”‡ Volume muted, auto-pausing...") + aps.client.Pause() + } else if oldState.Muted && !newState.Muted && newState.PlayStatus == "PAUSE_STATE" { + log.Println("šŸ”Š Volume unmuted, auto-resuming...") + aps.client.Play() + } +} +``` + +--- + +## šŸ‘„ **Multiroom Coordination** + +### Recipe: Party Mode Controller + +**Problem**: Synchronize multiple speakers for whole-house audio. + +```go +type PartyModeController struct { + masterClient *client.Client + allClients []*client.Client + zoneActive bool + masterID string +} + +func NewPartyModeController(clients []*client.Client) (*PartyModeController, error) { + if len(clients) == 0 { + return nil, fmt.Errorf("no clients provided") + } + + // Use first client as master + master := clients[0] + info, err := master.GetDeviceInfo() + if err != nil { + return nil, fmt.Errorf("failed to get master device info: %w", err) + } + + return &PartyModeController{ + masterClient: master, + allClients: clients, + masterID: info.DeviceID, + }, nil +} + +func (pmc *PartyModeController) StartPartyMode() error { + fmt.Println("šŸŽ‰ Starting party mode...") + + // Get all device IDs + var memberIDs []string + for i, client := range pmc.allClients[1:] { // Skip master + info, err := client.GetDeviceInfo() + if err != nil { + log.Printf("āš ļø Failed to get info for device %d: %v", i+1, err) + continue + } + memberIDs = append(memberIDs, info.DeviceID) + } + + if len(memberIDs) == 0 { + return fmt.Errorf("no member devices available") + } + + // Create zone + err := pmc.masterClient.CreateZone(pmc.masterID, memberIDs) + if err != nil { + return fmt.Errorf("failed to create zone: %w", err) + } + + pmc.zoneActive = true + fmt.Printf("āœ… Party mode active with %d speakers\n", len(memberIDs)+1) + + // Set reasonable volume for all speakers + return pmc.SetPartyVolume(40) +} + +func (pmc *PartyModeController) StopPartyMode() error { + if !pmc.zoneActive { + return nil + } + + fmt.Println("šŸ›‘ Stopping party mode...") + + err := pmc.masterClient.DissolveZone() + if err != nil { + return fmt.Errorf("failed to dissolve zone: %w", err) + } + + pmc.zoneActive = false + fmt.Println("āœ… Party mode stopped, speakers are now independent") + return nil +} + +func (pmc *PartyModeController) SetPartyVolume(level int) error { + if !pmc.zoneActive { + return fmt.Errorf("party mode not active") + } + + // Only master controls volume in a zone + return pmc.masterClient.SetVolume(level) +} + +func (pmc *PartyModeController) PlayPartyPlaylist() error { + if !pmc.zoneActive { + return fmt.Errorf("party mode not active") + } + + // Example: Select Spotify and play + if err := pmc.masterClient.SelectSpotify(); err != nil { + return fmt.Errorf("failed to select Spotify: %w", err) + } + + time.Sleep(time.Second) // Give source change time to process + + return pmc.masterClient.Play() +} + +func (pmc *PartyModeController) GetZoneStatus() (string, error) { + zone, err := pmc.masterClient.GetZone() + if err != nil { + return "", err + } + + if zone.IsStandalone() { + return "No zone active", nil + } + + return fmt.Sprintf("Zone active: master=%s, members=%d", + zone.Master, len(zone.Members)), nil +} +``` + +--- + +## āš ļø **Error Handling** + +### Recipe: Resilient Operation Wrapper + +**Problem**: Handle network issues, temporary failures, and device state conflicts gracefully. + +```go +type ResilientClient struct { + client *client.Client + maxRetries int + baseDelay time.Duration + maxDelay time.Duration + backoffRate float64 +} + +func NewResilientClient(c *client.Client) *ResilientClient { + return &ResilientClient{ + client: c, + maxRetries: 3, + baseDelay: time.Second, + maxDelay: 10 * time.Second, + backoffRate: 2.0, + } +} + +func (rc *ResilientClient) ExecuteWithRetry(operation func() error, operationName string) error { + var lastErr error + delay := rc.baseDelay + + for attempt := 1; attempt <= rc.maxRetries; attempt++ { + lastErr = operation() + if lastErr == nil { + if attempt > 1 { + log.Printf("āœ… %s succeeded on attempt %d", operationName, attempt) + } + return nil + } + + if !rc.isRetryableError(lastErr) { + return fmt.Errorf("%s failed (non-retryable): %w", operationName, lastErr) + } + + log.Printf("āš ļø %s attempt %d/%d failed: %v", operationName, attempt, rc.maxRetries, lastErr) + + if attempt < rc.maxRetries { + log.Printf("šŸ”„ Retrying in %v...", delay) + time.Sleep(delay) + delay = time.Duration(float64(delay) * rc.backoffRate) + if delay > rc.maxDelay { + delay = rc.maxDelay + } + } + } + + return fmt.Errorf("%s failed after %d attempts: %w", operationName, rc.maxRetries, lastErr) +} + +func (rc *ResilientClient) isRetryableError(err error) bool { + errStr := err.Error() + + // Network-related errors + retryablePatterns := []string{ + "connection refused", + "timeout", + "temporary failure", + "network is unreachable", + "no such host", + "connection reset", + "500", // Server errors + "502", // Bad gateway + "503", // Service unavailable + } + + for _, pattern := range retryablePatterns { + if strings.Contains(strings.ToLower(errStr), pattern) { + return true + } + } + + return false +} + +// Wrapper methods with automatic retry +func (rc *ResilientClient) SetVolume(level int) error { + return rc.ExecuteWithRetry(func() error { + return rc.client.SetVolume(level) + }, "SetVolume") +} + +func (rc *ResilientClient) Play() error { + return rc.ExecuteWithRetry(func() error { + return rc.client.Play() + }, "Play") +} + +func (rc *ResilientClient) SelectSource(source, account string) error { + return rc.ExecuteWithRetry(func() error { + return rc.client.SelectSource(source, account) + }, "SelectSource") +} +``` + +--- + +## šŸš€ **Performance Optimization** + +### Recipe: Connection Pool Manager + +**Problem**: Efficiently manage connections to multiple devices without resource waste. + +```go +type ConnectionPool struct { + clients map[string]*client.Client + mutex sync.RWMutex + maxIdle int + timeout time.Duration + lastUsed map[string]time.Time + cleanup *time.Ticker + stopChan chan struct{} +} + +func NewConnectionPool(maxIdle int, timeout time.Duration) *ConnectionPool { + cp := &ConnectionPool{ + clients: make(map[string]*client.Client), + maxIdle: maxIdle, + timeout: timeout, + lastUsed: make(map[string]time.Time), + stopChan: make(chan struct{}), + } + + // Start cleanup goroutine + cp.cleanup = time.NewTicker(timeout / 2) + go cp.cleanupLoop() + + return cp +} + +func (cp *ConnectionPool) GetClient(host string, port int) *client.Client { + key := fmt.Sprintf("%s:%d", host, port) + + cp.mutex.RLock() + if client, exists := cp.clients[key]; exists { + cp.mutex.RUnlock() + cp.mutex.Lock() + cp.lastUsed[key] = time.Now() + cp.mutex.Unlock() + return client + } + cp.mutex.RUnlock() + + // Create new client + config := client.ClientConfig{ + Host: host, + Port: port, + Timeout: 10 * time.Second, + } + + newClient := client.NewClient(config) + + cp.mutex.Lock() + cp.clients[key] = newClient + cp.lastUsed[key] = time.Now() + cp.mutex.Unlock() + + return newClient +} + +func (cp *ConnectionPool) cleanupLoop() { + for { + select { + case <-cp.cleanup.C: + cp.cleanupIdleConnections() + case <-cp.stopChan: + return + } + } +} + +func (cp *ConnectionPool) cleanupIdleConnections() { + cp.mutex.Lock() + defer cp.mutex.Unlock() + + now := time.Now() + var toDelete []string + + for key, lastUsed := range cp.lastUsed { + if now.Sub(lastUsed) > cp.timeout { + toDelete = append(toDelete, key) + } + } + + for _, key := range toDelete { + delete(cp.clients, key) + delete(cp.lastUsed, key) + log.Printf("šŸ—‘ļø Cleaned up idle connection: %s", key) + } +} + +func (cp *ConnectionPool) Close() { + close(cp.stopChan) + cp.cleanup.Stop() + + cp.mutex.Lock() + defer cp.mutex.Unlock() + + // Clean up all connections + for key := range cp.clients { + delete(cp.clients, key) + delete(cp.lastUsed, key) + } +} + +func (cp *ConnectionPool) Stats() (active int, idle int) { + cp.mutex.RLock() + defer cp.mutex.RUnlock() + + now := time.Now() + active = 0 + idle = 0 + + for _, lastUsed := range cp.lastUsed { + if now.Sub(lastUsed) < time.Minute { + active++ + } else { + idle++ + } + } + + return +} +``` + +--- + +## šŸ­ **Production Patterns** + +### Recipe: Configuration Management + +**Problem**: Manage different environments and settings cleanly. + +```go +type Config struct { + DeviceHosts []string `json:"device_hosts"` + DiscoveryTimeout time.Duration `json:"discovery_timeout"` + RequestTimeout time.Duration `json:"request_timeout"` + MaxRetries int `json:"max_retries"` + LogLevel string `json:"log_level"` + Features FeatureFlags `json:"features"` +} + +type FeatureFlags struct { + AutoDiscovery bool `json:"auto_discovery"` + HealthCheck bool `json:"health_check"` + PartyMode bool `json:"party_mode"` + Volu \ No newline at end of file diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..ba4ea7f --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,1074 @@ +# SoundTouch Production Deployment Guide + +**Best practices for deploying SoundTouch Go applications in production environments** + +This guide covers everything you need to know to deploy robust, scalable SoundTouch applications in production, including configuration management, monitoring, security, and operational considerations. + +## šŸ“‹ **Table of Contents** + +- [Architecture Considerations](#architecture-considerations) +- [Configuration Management](#configuration-management) +- [Security & Network](#security--network) +- [Monitoring & Logging](#monitoring--logging) +- [Performance Optimization](#performance-optimization) +- [Error Handling & Recovery](#error-handling--recovery) +- [Deployment Strategies](#deployment-strategies) +- [Maintenance & Operations](#maintenance--operations) + +--- + +## šŸ—ļø **Architecture Considerations** + +### Single-Device Applications + +**Use Case**: Home automation, personal music control + +```go +type SingleDeviceApp struct { + client *client.Client + wsClient *client.WebSocketClient + config Config + logger *log.Logger + metrics *Metrics +} + +func NewSingleDeviceApp(config Config) *SingleDeviceApp { + // Use resilient client with retries + resilientClient := NewResilientClient(client.NewClient(client.ClientConfig{ + Host: config.DeviceHost, + Port: config.DevicePort, + Timeout: config.RequestTimeout, + })) + + return &SingleDeviceApp{ + client: resilientClient, + config: config, + logger: log.New(os.Stdout, "[SoundTouch] ", log.LstdFlags), + } +} +``` + +### Multi-Device Applications + +**Use Case**: Commercial installations, whole-house systems + +```go +type MultiDeviceManager struct { + pool *ConnectionPool + devices map[string]*DeviceInfo + healthCheck *HealthChecker + config Config + metrics *prometheus.Registry +} + +type DeviceInfo struct { + Client *client.Client + Name string + Location string + Capabilities []string + LastSeen time.Time + Status DeviceStatus +} + +func NewMultiDeviceManager(config Config) *MultiDeviceManager { + return &MultiDeviceManager{ + pool: NewConnectionPool(config.MaxConnections, config.IdleTimeout), + devices: make(map[string]*DeviceInfo), + config: config, + } +} +``` + +### Microservice Architecture + +**Use Case**: Enterprise integrations, API services + +```go +// Service interface for dependency injection +type SoundTouchService interface { + GetDevices() ([]*DeviceInfo, error) + ControlDevice(deviceID string, action Action) error + GetDeviceStatus(deviceID string) (*Status, error) +} + +// Implementation with circuit breakers, metrics, tracing +type ProductionSoundTouchService struct { + manager *MultiDeviceManager + circuitBreaker *gobreaker.CircuitBreaker + tracer opentracing.Tracer + metrics metrics.Counter +} +``` + +--- + +## āš™ļø **Configuration Management** + +### Environment-Based Configuration + +```go +type Config struct { + // Server settings + ListenAddr string `env:"LISTEN_ADDR" default:":8080"` + + // SoundTouch settings + DeviceHosts []string `env:"DEVICE_HOSTS" separator:","` + DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"30s"` + RequestTimeout time.Duration `env:"REQUEST_TIMEOUT" default:"15s"` + MaxRetries int `env:"MAX_RETRIES" default:"3"` + + // Connection pool + MaxConnections int `env:"MAX_CONNECTIONS" default:"10"` + IdleTimeout time.Duration `env:"IDLE_TIMEOUT" default:"5m"` + + // Monitoring + MetricsEnabled bool `env:"METRICS_ENABLED" default:"true"` + HealthCheckInterval time.Duration `env:"HEALTH_CHECK_INTERVAL" default:"30s"` + + // Logging + LogLevel string `env:"LOG_LEVEL" default:"info"` + LogFormat string `env:"LOG_FORMAT" default:"json"` + + // Security + EnableTLS bool `env:"ENABLE_TLS" default:"false"` + TLSCertFile string `env:"TLS_CERT_FILE"` + TLSKeyFile string `env:"TLS_KEY_FILE"` +} + +func LoadConfig() (*Config, error) { + cfg := &Config{} + if err := env.Parse(cfg); err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + + return cfg, cfg.Validate() +} + +func (c *Config) Validate() error { + if len(c.DeviceHosts) == 0 { + return fmt.Errorf("at least one device host must be specified") + } + + if c.RequestTimeout < time.Second { + return fmt.Errorf("request timeout must be at least 1 second") + } + + if c.EnableTLS && (c.TLSCertFile == "" || c.TLSKeyFile == "") { + return fmt.Errorf("TLS cert and key files required when TLS is enabled") + } + + return nil +} +``` + +### Configuration File Support + +```yaml +# config/production.yaml +server: + listen_addr: ":8080" + enable_tls: true + tls_cert_file: "/etc/ssl/certs/app.crt" + tls_key_file: "/etc/ssl/private/app.key" + +soundtouch: + device_hosts: + - "192.168.1.100" + - "192.168.1.101" + discovery_timeout: "30s" + request_timeout: "15s" + max_retries: 3 + +pool: + max_connections: 20 + idle_timeout: "10m" + +monitoring: + metrics_enabled: true + health_check_interval: "30s" + +logging: + level: "info" + format: "json" +``` + +```go +func LoadConfigFromFile(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + + return &cfg, cfg.Validate() +} +``` + +--- + +## šŸ”’ **Security & Network** + +### Network Security + +```go +// Network configuration with security considerations +type SecureNetworkConfig struct { + // Allowed source IP ranges + AllowedCIDRs []string + + // Rate limiting + RateLimit int + RateLimitWindow time.Duration + + // TLS configuration + TLSConfig *tls.Config + + // Timeouts for security + ReadTimeout time.Duration + WriteTimeout time.Duration + IdleTimeout time.Duration +} + +func NewSecureServer(config SecureNetworkConfig) *http.Server { + mux := http.NewServeMux() + + // Add middleware + handler := applyMiddleware(mux, + corsMiddleware(), + rateLimitMiddleware(config.RateLimit, config.RateLimitWindow), + ipWhitelistMiddleware(config.AllowedCIDRs), + loggingMiddleware(), + metricsMiddleware(), + ) + + return &http.Server{ + Handler: handler, + TLSConfig: config.TLSConfig, + ReadTimeout: config.ReadTimeout, + WriteTimeout: config.WriteTimeout, + IdleTimeout: config.IdleTimeout, + } +} +``` + +### Input Validation + +```go +type DeviceControlRequest struct { + DeviceID string `json:"device_id" validate:"required,uuid"` + Action string `json:"action" validate:"required,oneof=play pause stop"` + Volume *int `json:"volume,omitempty" validate:"omitempty,min=0,max=100"` + Source string `json:"source,omitempty" validate:"omitempty,oneof=SPOTIFY BLUETOOTH AUX"` +} + +func (r *DeviceControlRequest) Validate() error { + validate := validator.New() + if err := validate.Struct(r); err != nil { + return fmt.Errorf("validation failed: %w", err) + } + + // Additional business logic validation + if r.Action == "volume" && r.Volume == nil { + return fmt.Errorf("volume value required for volume action") + } + + return nil +} +``` + +### Secrets Management + +```go +// Use environment variables or secret management systems +type SecretsConfig struct { + APIKeys map[string]string `env:"API_KEYS"` + TLSCert string `env:"TLS_CERT_PATH"` + TLSKey string `env:"TLS_KEY_PATH"` +} + +// For Kubernetes +func loadSecretsFromK8s() (*SecretsConfig, error) { + // Read from mounted secret volumes + tlsCert, err := os.ReadFile("/etc/secrets/tls.crt") + if err != nil { + return nil, err + } + + tlsKey, err := os.ReadFile("/etc/secrets/tls.key") + if err != nil { + return nil, err + } + + return &SecretsConfig{ + TLSCert: string(tlsCert), + TLSKey: string(tlsKey), + }, nil +} +``` + +--- + +## šŸ“Š **Monitoring & Logging** + +### Structured Logging + +```go +import ( + "github.com/sirupsen/logrus" + "github.com/prometheus/client_golang/prometheus" +) + +type Logger struct { + *logrus.Logger + deviceID string + component string +} + +func NewLogger(level, format, component string) (*Logger, error) { + logger := logrus.New() + + // Set level + logLevel, err := logrus.ParseLevel(level) + if err != nil { + return nil, err + } + logger.SetLevel(logLevel) + + // Set format + if format == "json" { + logger.SetFormatter(&logrus.JSONFormatter{ + TimestampFormat: time.RFC3339, + }) + } + + return &Logger{ + Logger: logger, + component: component, + }, nil +} + +func (l *Logger) WithDevice(deviceID string) *logrus.Entry { + return l.WithFields(logrus.Fields{ + "component": l.component, + "device_id": deviceID, + }) +} + +func (l *Logger) WithError(err error) *logrus.Entry { + return l.WithField("error", err.Error()) +} +``` + +### Metrics Collection + +```go +type Metrics struct { + // Request metrics + RequestsTotal prometheus.CounterVec + RequestDuration prometheus.HistogramVec + RequestsInFlight prometheus.GaugeVec + + // Device metrics + DevicesConnected prometheus.Gauge + DeviceHealth prometheus.GaugeVec + WebSocketConnections prometheus.Gauge + + // Error metrics + ErrorsTotal prometheus.CounterVec + + // Business metrics + VolumeChanges prometheus.CounterVec + SourceChanges prometheus.CounterVec + ZoneOperations prometheus.CounterVec +} + +func NewMetrics() *Metrics { + m := &Metrics{ + RequestsTotal: *prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "soundtouch_requests_total", + Help: "Total number of requests processed", + }, + []string{"method", "endpoint", "status"}, + ), + + RequestDuration: *prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "soundtouch_request_duration_seconds", + Help: "Request duration in seconds", + Buckets: prometheus.DefBuckets, + }, + []string{"method", "endpoint"}, + ), + + DevicesConnected: prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "soundtouch_devices_connected", + Help: "Number of connected devices", + }, + ), + + DeviceHealth: *prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "soundtouch_device_health", + Help: "Device health status (1=healthy, 0=unhealthy)", + }, + []string{"device_id", "device_name"}, + ), + } + + // Register metrics + prometheus.MustRegister( + m.RequestsTotal, + m.RequestDuration, + m.DevicesConnected, + m.DeviceHealth, + ) + + return m +} + +func (m *Metrics) RecordRequest(method, endpoint string, duration time.Duration, status int) { + m.RequestsTotal.WithLabelValues(method, endpoint, fmt.Sprintf("%d", status)).Inc() + m.RequestDuration.WithLabelValues(method, endpoint).Observe(duration.Seconds()) +} +``` + +### Health Checks + +```go +type HealthChecker struct { + manager *MultiDeviceManager + interval time.Duration + timeout time.Duration + metrics *Metrics + logger *Logger +} + +func (hc *HealthChecker) Start(ctx context.Context) { + ticker := time.NewTicker(hc.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + hc.checkAllDevices() + } + } +} + +func (hc *HealthChecker) checkAllDevices() { + var wg sync.WaitGroup + + for deviceID, device := range hc.manager.devices { + wg.Add(1) + go func(id string, dev *DeviceInfo) { + defer wg.Done() + hc.checkDevice(id, dev) + }(deviceID, device) + } + + wg.Wait() +} + +func (hc *HealthChecker) checkDevice(deviceID string, device *DeviceInfo) { + ctx, cancel := context.WithTimeout(context.Background(), hc.timeout) + defer cancel() + + start := time.Now() + err := device.Client.Ping() + duration := time.Since(start) + + if err != nil { + device.Status = DeviceStatusUnhealthy + hc.metrics.DeviceHealth.WithLabelValues(deviceID, device.Name).Set(0) + hc.logger.WithDevice(deviceID).WithError(err).Error("Device health check failed") + } else { + device.Status = DeviceStatusHealthy + device.LastSeen = time.Now() + hc.metrics.DeviceHealth.WithLabelValues(deviceID, device.Name).Set(1) + hc.logger.WithDevice(deviceID).WithField("duration", duration).Debug("Device health check passed") + } +} + +// HTTP health endpoint +func (hc *HealthChecker) HealthHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + healthy := 0 + total := 0 + + for _, device := range hc.manager.devices { + total++ + if device.Status == DeviceStatusHealthy { + healthy++ + } + } + + status := map[string]interface{}{ + "status": "ok", + "devices": map[string]interface{}{ + "total": total, + "healthy": healthy, + "unhealthy": total - healthy, + }, + "timestamp": time.Now().UTC(), + } + + w.Header().Set("Content-Type", "application/json") + + if healthy < total { + w.WriteHeader(http.StatusServiceUnavailable) + status["status"] = "degraded" + } + + json.NewEncoder(w).Encode(status) + } +} +``` + +--- + +## šŸš€ **Performance Optimization** + +### Connection Pooling + +```go +type ConnectionPool struct { + clients sync.Map + maxIdle int + maxActive int + idleTimeout time.Duration + activeCount int64 + metrics *Metrics + mu sync.RWMutex +} + +func NewConnectionPool(maxIdle, maxActive int, idleTimeout time.Duration) *ConnectionPool { + cp := &ConnectionPool{ + maxIdle: maxIdle, + maxActive: maxActive, + idleTimeout: idleTimeout, + } + + // Start cleanup goroutine + go cp.cleanup() + + return cp +} + +func (cp *ConnectionPool) Get(host string, port int) (*client.Client, error) { + key := fmt.Sprintf("%s:%d", host, port) + + // Check if connection exists and is valid + if val, ok := cp.clients.Load(key); ok { + conn := val.(*pooledConnection) + if time.Since(conn.lastUsed) < cp.idleTimeout { + conn.lastUsed = time.Now() + return conn.client, nil + } + // Connection expired, remove it + cp.clients.Delete(key) + } + + // Check active connection limit + if atomic.LoadInt64(&cp.activeCount) >= int64(cp.maxActive) { + return nil, fmt.Errorf("connection pool exhausted") + } + + // Create new connection + config := client.ClientConfig{ + Host: host, + Port: port, + Timeout: 15 * time.Second, + } + + newClient := client.NewClient(config) + + // Test connection + if err := newClient.Ping(); err != nil { + return nil, fmt.Errorf("failed to connect to %s:%d: %w", host, port, err) + } + + conn := &pooledConnection{ + client: newClient, + lastUsed: time.Now(), + created: time.Now(), + } + + cp.clients.Store(key, conn) + atomic.AddInt64(&cp.activeCount, 1) + + return newClient, nil +} + +type pooledConnection struct { + client *client.Client + lastUsed time.Time + created time.Time +} + +func (cp *ConnectionPool) cleanup() { + ticker := time.NewTicker(cp.idleTimeout / 2) + defer ticker.Stop() + + for range ticker.C { + now := time.Now() + cp.clients.Range(func(key, val interface{}) bool { + conn := val.(*pooledConnection) + if now.Sub(conn.lastUsed) > cp.idleTimeout { + cp.clients.Delete(key) + atomic.AddInt64(&cp.activeCount, -1) + } + return true + }) + } +} +``` + +### Caching Strategy + +```go +type CacheManager struct { + deviceInfoCache *cache.Cache + capabilitiesCache *cache.Cache + volumeCache *cache.Cache +} + +func NewCacheManager() *CacheManager { + return &CacheManager{ + // Device info rarely changes, cache for 1 hour + deviceInfoCache: cache.New(1*time.Hour, 2*time.Hour), + + // Capabilities never change, cache for 24 hours + capabilitiesCache: cache.New(24*time.Hour, 48*time.Hour), + + // Volume changes frequently, cache for 5 seconds + volumeCache: cache.New(5*time.Second, 10*time.Second), + } +} + +func (cm *CacheManager) GetDeviceInfo(deviceID string, fetcher func() (*models.DeviceInfo, error)) (*models.DeviceInfo, error) { + if cached, found := cm.deviceInfoCache.Get(deviceID); found { + return cached.(*models.DeviceInfo), nil + } + + info, err := fetcher() + if err != nil { + return nil, err + } + + cm.deviceInfoCache.Set(deviceID, info, cache.DefaultExpiration) + return info, nil +} +``` + +--- + +## šŸ›”ļø **Error Handling & Recovery** + +### Circuit Breaker Pattern + +```go +import "github.com/sony/gobreaker" + +type ResilientSoundTouchService struct { + client *client.Client + cb *gobreaker.CircuitBreaker + metrics *Metrics +} + +func NewResilientSoundTouchService(client *client.Client) *ResilientSoundTouchService { + settings := gobreaker.Settings{ + Name: "soundtouch", + MaxRequests: 3, + Interval: 10 * time.Second, + Timeout: 30 * time.Second, + ReadyToTrip: func(counts gobreaker.Counts) bool { + failureRatio := float64(counts.TotalFailures) / float64(counts.Requests) + return counts.Requests >= 3 && failureRatio >= 0.6 + }, + OnStateChange: func(name string, from gobreaker.State, to gobreaker.State) { + log.Printf("Circuit breaker '%s' changed from '%s' to '%s'", name, from, to) + }, + } + + return &ResilientSoundTouchService{ + client: client, + cb: gobreaker.NewCircuitBreaker(settings), + } +} + +func (r *ResilientSoundTouchService) SetVolume(deviceID string, volume int) error { + result, err := r.cb.Execute(func() (interface{}, error) { + return nil, r.client.SetVolume(volume) + }) + + if err != nil { + r.metrics.ErrorsTotal.WithLabelValues("circuit_breaker", "volume").Inc() + return err + } + + return result.(error) +} +``` + +### Graceful Shutdown + +```go +func (app *Application) Run(ctx context.Context) error { + // Setup signal handling + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + // Start services + g, ctx := errgroup.WithContext(ctx) + + // HTTP server + server := &http.Server{ + Addr: app.config.ListenAddr, + Handler: app.handler, + } + + g.Go(func() error { + app.logger.Info("Starting HTTP server", "addr", app.config.ListenAddr) + if err := server.ListenAndServe(); err != http.ErrServerClosed { + return err + } + return nil + }) + + // Health checker + g.Go(func() error { + return app.healthChecker.Start(ctx) + }) + + // WebSocket manager + g.Go(func() error { + return app.wsManager.Start(ctx) + }) + + // Wait for shutdown signal + go func() { + <-sigChan + app.logger.Info("Shutdown signal received") + + // Graceful shutdown with timeout + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Shutdown HTTP server + if err := server.Shutdown(shutdownCtx); err != nil { + app.logger.Error("HTTP server shutdown error", "error", err) + } + + // Close WebSocket connections + app.wsManager.Shutdown(shutdownCtx) + + // Close connection pool + app.connectionPool.Close() + }() + + return g.Wait() +} +``` + +--- + +## 🚢 **Deployment Strategies** + +### Docker Deployment + +```dockerfile +# Dockerfile +FROM golang:1.21-alpine AS builder + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main . + +FROM alpine:latest +RUN apk --no-cache add ca-certificates +WORKDIR /root/ + +COPY --from=builder /app/main . +COPY --from=builder /app/config ./config + +EXPOSE 8080 +CMD ["./main"] +``` + +```yaml +# docker-compose.yml +version: '3.8' + +services: + soundtouch-app: + build: . + ports: + - "8080:8080" + environment: + - DEVICE_HOSTS=192.168.1.100,192.168.1.101 + - LOG_LEVEL=info + - METRICS_ENABLED=true + volumes: + - ./config:/app/config:ro + - ./logs:/app/logs + networks: + - soundtouch-net + restart: unless-stopped + + prometheus: + image: prom/prometheus:latest + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + networks: + - soundtouch-net + + grafana: + image: grafana/grafana:latest + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + volumes: + - grafana-storage:/var/lib/grafana + networks: + - soundtouch-net + +networks: + soundtouch-net: + +volumes: + grafana-storage: +``` + +### Kubernetes Deployment + +```yaml +# k8s-deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: soundtouch-app +spec: + replicas: 3 + selector: + matchLabels: + app: soundtouch-app + template: + metadata: + labels: + app: soundtouch-app + spec: + containers: + - name: soundtouch-app + image: your-repo/soundtouch-app:latest + ports: + - containerPort: 8080 + env: + - name: DEVICE_HOSTS + valueFrom: + configMapKeyRef: + name: soundtouch-config + key: device_hosts + - name: LOG_LEVEL + value: "info" + resources: + requests: + memory: "64Mi" + cpu: "250m" + limits: + memory: "128Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /ready + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + +--- +apiVersion: v1 +kind: Service +metadata: + name: soundtouch-service +spec: + selector: + app: soundtouch-app + ports: + - port: 80 + targetPort: 8080 + type: LoadBalancer + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: soundtouch-config +data: + device_hosts: "192.168.1.100,192.168.1.101,192.168.1.102" +``` + +### Systemd Service + +```ini +# /etc/systemd/system/soundtouch.service +[Unit] +Description=SoundTouch Control Service +After=network.target +Wants=network.target + +[Service] +Type=simple +User=soundtouch +Group=soundtouch +WorkingDirectory=/opt/soundtouch +ExecStart=/opt/soundtouch/bin/soundtouch-app +ExecReload=/bin/kill -HUP $MAINPID +Restart=always +RestartSec=5 +Environment=DEVICE_HOSTS=192.168.1.100,192.168.1.101 +Environment=LOG_LEVEL=info +Environment=CONFIG_FILE=/opt/soundtouch/config/production.yaml + +# Security settings +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/soundtouch/logs +PrivateTmp=true + +[Install] +WantedBy=multi-user.target +``` + +--- + +## šŸ”§ **Maintenance & Operations** + +### Log Rotation + +```bash +# /etc/logrotate.d/soundtouch +/opt/soundtouch/logs/*.log { + daily + missingok + rotate 14 + compress + delaycompress + notifempty + create 0644 soundtouch soundtouch + postrotate + /bin/kill -HUP `cat /var/run/soundtouch.pid 2>/dev/null` 2>/dev/null || true + endscript +} +``` + +### Monitoring Alerts + +```yaml +# Prometheus alerts +groups: +- name: soundtouch + rules: + - alert: DeviceUnhealthy + expr: soundtouch_device_health == 0 + for: 2m + labels: + severity: warning + annotations: + summary: "SoundTouch device {{ $labels.device_name }} is unhealthy" + description: "Device {{ $labels.device_id }} has been unhealthy for more than 2 minutes" + + - alert: HighErrorRate + expr: rate(soundtouch_errors_total[5m]) > 0.1 + for: 5m + labels: + severity: critical + annotations: + summary: "High error rate detected" + description: "Error rate is {{ $value }} errors/second over the last 5 minutes" + + - alert: ServiceDown + expr: up{job="soundtouch"} == 0 + for: 1m + labels: + severity: critical + annotations: + summary: "SoundTouch service is down" + description: "SoundTouch service has been down for more than 1 minute" +``` + +### Backup Strategy + +```go +// Backup device configurations +func (m *Manager) BackupConfigurations() error { + backup := ConfigBackup{ + Timestamp: time.Now(), + Devices: make(map[string]DeviceConfig), + } + + for deviceID, device := range m.devices { + config := DeviceConfig{} + + // Backup presets + if presets, err := device.Client.GetPresets(); err == nil { + config.Presets = presets + } + + // Backup settings + if volume, err := device.Client.GetVolume(); err == nil { + config.Volume = volume.TargetVolume + } + + if bass, err := device.Client.GetBass(); err == nil { + config.Bass = bass.TargetBass + } + + backup.Devices[deviceID] = config + } + + // Save to file + data, err := json.MarshalIndent(backup, "", " ") + if err != nil { + return err + } + + filename := fmt.Sprintf("backup_%s.json", time.Now().Format("2006-01-02_15-04-05")) + return os.WriteFile(filepath.Join(m.config.BackupDir, filename), data, 0644) +} +``` + +### Performance Tuning + +```go +// Tune Go runtime for production +func init() { + // Set GOMAXPROCS based on container limits if not set + if os.Getenv("GOMAXPROCS") == "" { + if limit := getCgroupCPULimit(); limit > 0 { + runtime.GOMAXPROCS(int(limit)) + } + } + + // Set GC target percentage + if os.Getenv("GOGC") == "" { + debug.SetGCPerc \ No newline at end of file diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md new file mode 100644 index 0000000..21736ee --- /dev/null +++ b/docs/GETTING-STARTED.md @@ -0,0 +1,491 @@ +# Getting Started with SoundTouch Go Client + +**A complete guide to controlling your Bose SoundTouch devices with Go** + +This guide will get you up and running with the SoundTouch Go client in under 10 minutes. By the end, you'll be able to discover devices, control playback, manage volume, and monitor real-time events. + +## šŸ“‹ **Prerequisites** + +- **Go 1.19 or later** installed on your system +- **Bose SoundTouch device** on your network (SoundTouch 10, 20, 30, etc.) +- **Same network** - Your computer and SoundTouch device must be on the same network + +## šŸš€ **Quick Start** + +### Step 1: Create a New Go Project + +```bash +mkdir soundtouch-example +cd soundtouch-example +go mod init soundtouch-example +``` + +### Step 2: Add the SoundTouch Client + +```bash +go get github.com/user_account/bose-soundtouch +``` + +### Step 3: Find Your Device + +Create `main.go`: + +```go +package main + +import ( + "fmt" + "log" + "time" + + "github.com/user_account/bose-soundtouch/pkg/discovery" +) + +func main() { + // Discover devices on your network + discoverer := discovery.NewDiscoverer(discovery.Config{ + Timeout: 10 * time.Second, + }) + + fmt.Println("šŸ” Discovering SoundTouch devices...") + devices, err := discoverer.DiscoverDevices() + if err != nil { + log.Fatalf("Discovery failed: %v", err) + } + + if len(devices) == 0 { + fmt.Println("āŒ No devices found. Make sure your SoundTouch is on and connected.") + return + } + + fmt.Printf("āœ… Found %d device(s):\n", len(devices)) + for i, device := range devices { + fmt.Printf("%d. %s at %s:%d\n", i+1, device.Name, device.Host, device.Port) + } +} +``` + +Run it: +```bash +go run main.go +``` + +You should see your SoundTouch device(s) listed! + +### Step 4: Control Your Device + +Now let's add basic control functionality: + +```go +package main + +import ( + "fmt" + "log" + "time" + + "github.com/user_account/bose-soundtouch/pkg/client" + "github.com/user_account/bose-soundtouch/pkg/discovery" +) + +func main() { + // Discover and connect to first device + discoverer := discovery.NewDiscoverer(discovery.Config{ + Timeout: 5 * time.Second, + }) + + devices, err := discoverer.DiscoverDevices() + if err != nil || len(devices) == 0 { + log.Fatal("No devices found") + } + + // Connect to the first device + soundtouch := client.NewClient(client.ClientConfig{ + Host: devices[0].Host, + Port: devices[0].Port, + Timeout: 10 * time.Second, + }) + + // Get device information + info, err := soundtouch.GetDeviceInfo() + if err != nil { + log.Fatalf("Failed to connect: %v", err) + } + + fmt.Printf("šŸŽµ Connected to: %s\n", info.Name) + fmt.Printf(" Type: %s\n", info.Type) + fmt.Printf(" ID: %s\n", info.DeviceID) + + // Basic controls + fmt.Println("\nšŸŽ® Testing basic controls...") + + // Set volume to 30 + fmt.Println("Setting volume to 30...") + if err := soundtouch.SetVolume(30); err != nil { + fmt.Printf("Volume control failed: %v\n", err) + } + + // Play + fmt.Println("Sending PLAY command...") + if err := soundtouch.Play(); err != nil { + fmt.Printf("Play failed: %v\n", err) + } + + // Wait a moment + time.Sleep(2 * time.Second) + + // Pause + fmt.Println("Sending PAUSE command...") + if err := soundtouch.Pause(); err != nil { + fmt.Printf("Pause failed: %v\n", err) + } + + // Get current status + nowPlaying, err := soundtouch.GetNowPlaying() + if err == nil { + fmt.Printf("\nšŸ“Š Current Status:\n") + fmt.Printf(" Source: %s\n", nowPlaying.Source) + if nowPlaying.Track != "" { + fmt.Printf(" Track: %s\n", nowPlaying.Track) + } + if nowPlaying.Artist != "" { + fmt.Printf(" Artist: %s\n", nowPlaying.Artist) + } + fmt.Printf(" Status: %s\n", nowPlaying.PlayStatus) + } + + fmt.Println("\nāœ… Basic setup complete!") +} +``` + +## šŸ“– **Core Concepts** + +### Device Discovery + +The SoundTouch library supports multiple discovery methods: + +```go +// UPnP discovery (default) +discoverer := discovery.NewDiscoverer(discovery.Config{ + Timeout: 10 * time.Second, +}) +devices, err := discoverer.DiscoverDevices() + +// Or connect directly if you know the IP +soundtouch := client.NewClientFromHost("192.168.1.100") +``` + +### Client Configuration + +```go +config := client.ClientConfig{ + Host: "192.168.1.100", + Port: 8090, // Default SoundTouch port + Timeout: 10 * time.Second, + UserAgent: "MyApp/1.0", // Optional +} +soundtouch := client.NewClient(config) +``` + +### Error Handling + +Always check for errors, especially with network operations: + +```go +volume, err := soundtouch.GetVolume() +if err != nil { + log.Printf("Failed to get volume: %v", err) + return +} + +fmt.Printf("Current volume: %d\n", volume.TargetVolume) +if volume.Muted { + fmt.Println("Device is muted") +} +``` + +## šŸŽµ **Common Operations** + +### Playback Control + +```go +// Basic playback +soundtouch.Play() +soundtouch.Pause() +soundtouch.Stop() + +// Navigation +soundtouch.NextTrack() +soundtouch.PrevTrack() + +// Power and mute +soundtouch.SendKey("POWER") +soundtouch.SendKey("MUTE") +``` + +### Volume Management + +```go +// Get current volume +volume, err := soundtouch.GetVolume() +if err == nil { + fmt.Printf("Volume: %d, Muted: %t\n", volume.TargetVolume, volume.Muted) +} + +// Set volume (0-100) +soundtouch.SetVolume(50) + +// Incremental control +soundtouch.VolumeUp() +soundtouch.VolumeDown() + +// Safe volume setting (clamps to valid range) +soundtouch.SetVolumeSafe(150) // Will be set to 100 +``` + +### Source Selection + +```go +// Get available sources +sources, err := soundtouch.GetSources() +if err == nil { + for _, source := range sources.Sources { + fmt.Printf("Source: %s (%s)\n", source.Source, source.Status) + } +} + +// Select sources +soundtouch.SelectSpotify() +soundtouch.SelectBluetooth() +soundtouch.SelectAux() + +// Or select by name +soundtouch.SelectSource("SPOTIFY", "") +``` + +### Preset Management + +```go +// Get presets +presets, err := soundtouch.GetPresets() +if err == nil { + for _, preset := range presets.Presets { + fmt.Printf("Preset %d: %s\n", preset.ID, preset.ContentItem.ItemName) + } +} + +// Select preset (1-6) +soundtouch.SelectPreset(1) + +// Or use key command +soundtouch.SendKey("PRESET_3") +``` + +### Device Information + +```go +// Basic device info +info, _ := soundtouch.GetDeviceInfo() +fmt.Printf("Device: %s (%s)\n", info.Name, info.Type) + +// Capabilities +caps, _ := soundtouch.GetCapabilities() +fmt.Printf("Bass control: %t\n", caps.BassCapable) + +// Network information +network, _ := soundtouch.GetNetworkInfo() +for _, iface := range network.GetInterfaces() { + fmt.Printf("Interface: %s - %s\n", iface.Type, iface.IPAddress) +} +``` + +## 🌐 **Real-time Monitoring** + +One of the most powerful features is real-time event monitoring: + +```go +package main + +import ( + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "github.com/user_account/bose-soundtouch/pkg/client" +) + +func main() { + // Connect to device + soundtouch := client.NewClientFromHost("192.168.1.100") + + // Create WebSocket client + wsClient := soundtouch.NewWebSocketClient(nil) + + // Set up event handlers + wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) { + fmt.Printf("šŸŽµ Now Playing: %s - %s\n", + event.NowPlaying.Artist, event.NowPlaying.Track) + }) + + wsClient.OnVolumeUpdated(func(event *models.VolumeUpdatedEvent) { + fmt.Printf("šŸ”Š Volume: %d\n", event.Volume.TargetVolume) + }) + + // Connect to WebSocket + if err := wsClient.Connect(); err != nil { + log.Fatalf("WebSocket connection failed: %v", err) + } + + fmt.Println("āœ… Monitoring events... Press Ctrl+C to exit") + + // Wait for interrupt + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + // Cleanup + wsClient.Disconnect() + fmt.Println("Disconnected.") +} +``` + +## šŸ‘„ **Multiroom Setup** + +Control multiple speakers together: + +```go +// Get current zone configuration +zone, err := soundtouch.GetZone() +if err == nil { + fmt.Printf("Zone master: %s\n", zone.Master) + fmt.Printf("Members: %d\n", len(zone.Members)) +} + +// Create a zone (master + members) +masterID := "DEVICE123" +memberIDs := []string{"DEVICE456", "DEVICE789"} +soundtouch.CreateZone(masterID, memberIDs) + +// Add device to existing zone +soundtouch.AddToZone("DEVICE999", "192.168.1.15") + +// Remove device from zone +soundtouch.RemoveFromZone("DEVICE456") + +// Dissolve zone (make all devices standalone) +soundtouch.DissolveZone() +``` + +## āš ļø **Common Issues & Solutions** + +### Device Not Found +``` +āŒ No devices found +``` +**Solutions:** +- Ensure SoundTouch is powered on +- Check both devices are on same network +- Try specifying IP directly: `client.NewClientFromHost("192.168.1.100")` +- Check firewall settings + +### Connection Timeouts +``` +āŒ Failed to connect: context deadline exceeded +``` +**Solutions:** +- Increase timeout: `Timeout: 30 * time.Second` +- Verify IP address and port (default 8090) +- Check network connectivity with `ping 192.168.1.100` + +### Volume/Control Issues +``` +āŒ Volume control failed +``` +**Solutions:** +- Check device isn't in a zone (members can't control volume directly) +- Ensure device isn't in setup mode +- Try basic commands first (play, pause) + +### WebSocket Connection Issues +``` +āŒ WebSocket connection failed +``` +**Solutions:** +- WebSocket uses port 8080 (not 8090) +- Ensure no other apps are connected +- Try disconnecting and reconnecting + +## šŸ”§ **Development Tips** + +### Enable Debug Logging + +```go +// For HTTP requests +import "net/http/httputil" + +// Custom transport for debugging +transport := &http.Transport{} +httpClient := &http.Client{ + Transport: transport, + Timeout: 10 * time.Second, +} + +// Use with client... +``` + +### Testing with CLI Tool + +Use the included CLI for quick testing: + +```bash +# Discovery +go run ./cmd/soundtouch-cli -discover + +# Device info +go run ./cmd/soundtouch-cli -host 192.168.1.100 -info + +# Basic controls +go run ./cmd/soundtouch-cli -host 192.168.1.100 -play -volume 50 + +# WebSocket monitoring (separate terminal) +go run ./cmd/soundtouch-cli -host 192.168.1.100 -monitor +``` + +### Configuration Management + +```go +// Use environment variables +import "os" + +host := os.Getenv("SOUNDTOUCH_HOST") +if host == "" { + host = "192.168.1.100" // fallback +} + +soundtouch := client.NewClientFromHost(host) +``` + +## šŸ“š **Next Steps** + +Now that you have the basics working: + +1. **Explore Examples**: Check out `/examples` directory for more advanced usage +2. **API Reference**: Read `/docs/API-Endpoints-Overview.md` for complete API details +3. **WebSocket Events**: See `/docs/websocket-events.md` for real-time monitoring +4. **Multiroom Guide**: Check `/docs/zone-management.md` for multiroom setup +5. **Production Guide**: Read `/docs/DEPLOYMENT.md` for production considerations + +## šŸ›Ÿ **Getting Help** + +- **Issues**: Create an issue on GitHub with device model and Go version +- **API Reference**: See comprehensive documentation in `/docs` +- **Examples**: Check `/examples` directory for working code samples +- **CLI Tool**: Use the included CLI for testing and debugging + +## šŸŽ‰ **You're Ready!** + +You now have everything needed to build amazing SoundTouch integrations! The library handles all the complexity of device communication, XML parsing, WebSocket management, and error handling - you can focus on building great user experiences. + +**Happy coding!** šŸŽµ \ No newline at end of file diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..76b7fa6 --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,781 @@ +# SoundTouch Troubleshooting Guide + +**Complete guide to diagnosing and fixing common SoundTouch Go client issues** + +This guide helps you quickly identify and resolve problems with the SoundTouch Go client library. Issues are organized by category with step-by-step solutions. + +## 🚨 **Quick Diagnostics** + +### Test Your Setup +Run these commands to quickly diagnose your setup: + +```bash +# 1. Test discovery +go run ./cmd/soundtouch-cli -discover + +# 2. Test specific device connection +go run ./cmd/soundtouch-cli -host 192.168.1.100 -info + +# 3. Test basic controls +go run ./cmd/soundtouch-cli -host 192.168.1.100 -volume + +# 4. Test network connectivity +ping 192.168.1.100 +``` + +--- + +## šŸ” **Discovery Issues** + +### āŒ "No devices found" + +**Symptoms:** +``` +šŸ” Discovering SoundTouch devices... +āŒ No devices found on the network +``` + +**Causes & Solutions:** + +#### 1. **Network Configuration** +```bash +# Check if devices are on same network +ip route show default # Your gateway +arp -a | grep -i bose # Look for Bose devices +``` + +**Solution:** Ensure both your computer and SoundTouch are on the same subnet. + +#### 2. **Firewall Issues** +```bash +# Check if firewall is blocking UPnP +sudo ufw status # Ubuntu +netsh advfirewall show allprofiles # Windows +``` + +**Solution:** Allow UPnP traffic (port 1900 UDP) or temporarily disable firewall. + +#### 3. **Device Not Ready** +- Power cycle your SoundTouch device +- Wait 30 seconds for full boot +- Check device is connected to network (solid white LED) + +#### 4. **Discovery Timeout Too Short** +```go +discoverer := discovery.NewDiscoverer(discovery.Config{ + Timeout: 30 * time.Second, // Increase timeout +}) +``` + +#### 5. **Use Manual IP** +```go +// Bypass discovery entirely +client := client.NewClientFromHost("192.168.1.100") +``` + +### āŒ "Discovery timeout" + +**Symptoms:** +``` +šŸ” Discovering SoundTouch devices (timeout: 5s)... +āŒ Discovery failed: context deadline exceeded +``` + +**Solutions:** + +1. **Increase timeout:** +```go +discoverer := discovery.NewDiscoverer(discovery.Config{ + Timeout: 15 * time.Second, +}) +``` + +2. **Check network performance:** +```bash +# Test network latency +ping -c 4 192.168.1.1 + +# Check for network congestion +iperf3 -c 192.168.1.1 # If iperf server available +``` + +3. **Use wired connection if possible** + +--- + +## 🌐 **Connection Issues** + +### āŒ "Connection refused" + +**Symptoms:** +```go +Failed to connect: dial tcp 192.168.1.100:8090: connection refused +``` + +**Diagnostic Steps:** + +#### 1. **Verify IP and Port** +```bash +# Test if port 8090 is open +telnet 192.168.1.100 8090 +# OR +nc -zv 192.168.1.100 8090 + +# Scan for open ports +nmap -p 8080-8100 192.168.1.100 +``` + +#### 2. **Check Device Status** +- Device LED should be solid white (connected) +- Blinking white = connecting +- Red = error state + +#### 3. **Router/Network Issues** +```bash +# Check routing +traceroute 192.168.1.100 + +# Test basic connectivity +ping -c 4 192.168.1.100 +``` + +### āŒ "Timeout" / "Context deadline exceeded" + +**Symptoms:** +```go +Failed to get device info: context deadline exceeded +``` + +**Solutions:** + +#### 1. **Increase Client Timeout** +```go +config := client.ClientConfig{ + Host: "192.168.1.100", + Port: 8090, + Timeout: 30 * time.Second, // Increase from default 10s +} +``` + +#### 2. **Check Network Latency** +```bash +# Test response time +ping -c 10 192.168.1.100 + +# Should be < 100ms typically +``` + +#### 3. **Device Performance Issues** +- Device may be overloaded +- Try power cycling the device +- Check for firmware updates via Bose app + +### āŒ "No such host" + +**Symptoms:** +```go +Failed to connect: dial tcp: lookup soundtouch.local: no such host +``` + +**Solutions:** + +1. **Use IP instead of hostname:** +```go +client := client.NewClientFromHost("192.168.1.100") // Not "soundtouch.local" +``` + +2. **Fix DNS/mDNS:** +```bash +# Test hostname resolution +nslookup soundtouch.local +dig soundtouch.local + +# Install mDNS tools if needed (Linux) +sudo apt-get install avahi-utils +avahi-resolve -n soundtouch.local +``` + +--- + +## šŸŽµ **Playback Control Issues** + +### āŒ "Play/Pause not working" + +**Symptoms:** +- Commands succeed but no audio change +- Device shows wrong status + +**Diagnostic Steps:** + +#### 1. **Check Current Status** +```go +nowPlaying, err := client.GetNowPlaying() +if err == nil { + fmt.Printf("Status: %s, Source: %s\n", + nowPlaying.PlayStatus, nowPlaying.Source) +} +``` + +#### 2. **Verify Source Selection** +```go +sources, err := client.GetSources() +if err == nil { + for _, source := range sources.Sources { + fmt.Printf("Source: %s, Status: %s\n", + source.Source, source.Status) + } +} +``` + +**Solutions:** + +1. **Select active source first:** +```go +client.SelectSpotify() +time.Sleep(2 * time.Second) // Wait for source change +client.Play() +``` + +2. **Use key commands instead:** +```go +client.SendKey("PLAY") // Instead of client.Play() +client.SendKey("PAUSE") // Instead of client.Pause() +``` + +3. **Check device isn't in setup mode** + +### āŒ "Source selection fails" + +**Symptoms:** +```go +Failed to select source: API request failed with status 500 +``` + +**Solutions:** + +1. **Check source availability:** +```go +sources, _ := client.GetSources() +for _, source := range sources.Sources { + if source.Source == "SPOTIFY" && source.Status == "READY" { + // Source is available + client.SelectSource("SPOTIFY", source.SourceAccount) + } +} +``` + +2. **Account-specific sources:** +```go +// For streaming services, include account +client.SelectSource("SPOTIFY", "your_account_id") +``` + +3. **Use convenience methods:** +```go +client.SelectSpotify() // Handles account automatically +client.SelectBluetooth() +client.SelectAux() +``` + +--- + +## šŸ”Š **Volume & Audio Issues** + +### āŒ "Volume control not working" + +**Symptoms:** +- Volume commands succeed but no change +- "Permission denied" errors + +**Diagnostic Steps:** + +#### 1. **Check Zone Status** +```go +zoneStatus, err := client.GetZoneStatus() +if err == nil { + fmt.Printf("Zone Status: %s\n", zoneStatus) +} +``` + +**Solutions:** + +1. **Zone Member Issue:** +```go +// Only zone master can control volume +if zoneStatus == "MEMBER" { + fmt.Println("Device is zone member - only master controls volume") + + // Find and use master device + zone, _ := client.GetZone() + // Connect to master device using zone.Master ID +} +``` + +2. **Use Safe Volume Methods:** +```go +client.SetVolumeSafe(50) // Clamps to valid range +client.IncreaseVolume(5) // Incremental control +client.DecreaseVolume(5) +``` + +3. **Check Current Volume:** +```go +volume, _ := client.GetVolume() +fmt.Printf("Target: %d, Actual: %d, Muted: %t\n", + volume.TargetVolume, volume.ActualVolume, volume.Muted) +``` + +### āŒ "Bass/Balance control not supported" + +**Symptoms:** +```go +Failed to set bass: API request failed with status 404 +``` + +**Solutions:** + +1. **Check device capabilities:** +```go +caps, err := client.GetCapabilities() +if err == nil { + fmt.Printf("Bass capable: %t\n", caps.BassCapable) +} +``` + +2. **Use safe methods:** +```go +client.SetBassSafe(-5) // Won't fail on unsupported devices +client.SetBalanceSafe(10) // Falls back gracefully +``` + +3. **Device-specific features:** +- SoundTouch 10: Basic bass only +- SoundTouch 20/30: Full bass and balance +- Soundbar models: Advanced audio controls + +--- + +## šŸ“” **WebSocket Issues** + +### āŒ "WebSocket connection failed" + +**Symptoms:** +```go +Failed to connect WebSocket: dial ws://192.168.1.100:8080/: connection refused +``` + +**Solutions:** + +#### 1. **Verify WebSocket Port (8080)** +```bash +# WebSocket uses port 8080, not 8090 +nc -zv 192.168.1.100 8080 +``` + +#### 2. **Check Protocol Specification** +```go +// WebSocket client should auto-handle this +wsClient := client.NewWebSocketClient(nil) + +// Manual connection (if needed) +url := "ws://192.168.1.100:8080/" +headers := http.Header{} +headers.Set("Sec-WebSocket-Protocol", "gabbo") +``` + +#### 3. **Connection Conflicts** +- Only one WebSocket connection per device +- Close other apps using SoundTouch +- Restart SoundTouch device if needed + +### āŒ "WebSocket disconnects frequently" + +**Symptoms:** +- Connection drops every few minutes +- Constant reconnection messages + +**Solutions:** + +1. **Increase ping interval:** +```go +config := client.DefaultWebSocketConfig() +config.PingInterval = 60 * time.Second // Increase from 30s +config.PongTimeout = 20 * time.Second // Increase timeout + +wsClient := client.NewWebSocketClient(config) +``` + +2. **Check network stability:** +```bash +# Test for packet loss +ping -c 100 192.168.1.100 | grep loss +``` + +3. **Power management issues:** +```bash +# Disable WiFi power saving (Linux) +sudo iwconfig wlan0 power off + +# Check Windows power management +powercfg -devicequery wake_armed +``` + +### āŒ "Events not received" + +**Symptoms:** +- WebSocket connects but no events +- Missing volume/playback updates + +**Solutions:** + +1. **Verify event handlers:** +```go +wsClient.OnVolumeUpdated(func(event *models.VolumeUpdatedEvent) { + fmt.Printf("Volume event received: %d\n", event.Volume.TargetVolume) +}) + +// Test by manually changing volume on device +``` + +2. **Check event parsing:** +```go +wsClient.OnUnknownEvent(func(event *models.WebSocketEvent) { + fmt.Printf("Unknown event: %+v\n", event) +}) +``` + +3. **Device activity required:** +- Events only sent when device state changes +- Try manual volume/source changes +- Check device isn't in standby + +--- + +## šŸ‘„ **Multiroom Issues** + +### āŒ "Zone creation fails" + +**Symptoms:** +```go +Failed to create zone: API request failed with status 400 +``` + +**Solutions:** + +#### 1. **Check Device Compatibility** +```go +// Get device capabilities +caps, _ := client.GetCapabilities() +// Look for multiroom support + +// Verify devices are on same network +for _, client := range clients { + network, _ := client.GetNetworkInfo() + fmt.Printf("Device IP: %s\n", network.GetConnectedInterface().IPAddress) +} +``` + +#### 2. **Correct Device IDs** +```go +// Get exact device IDs +info, _ := client.GetDeviceInfo() +masterID := info.DeviceID // Use this, not MAC address + +// Create zone with proper IDs +client.CreateZone(masterID, []string{member1ID, member2ID}) +``` + +#### 3. **Sequential Zone Operations** +```go +// Don't create multiple zones simultaneously +client1.CreateZone(master1, []string{member1}) +time.Sleep(2 * time.Second) +client2.CreateZone(master2, []string{member2}) +``` + +### āŒ "Device won't join zone" + +**Symptoms:** +- Zone creation succeeds but member doesn't join +- Member device shows as standalone + +**Solutions:** + +1. **Check device status:** +```go +status, _ := memberClient.GetZoneStatus() +fmt.Printf("Member status: %s\n", status) + +if status == "STANDALONE" { + // Device didn't join - check network/permissions +} +``` + +2. **Firmware compatibility:** +- Ensure all devices have recent firmware +- Update via Bose SoundTouch app +- Some very old devices don't support multiroom + +3. **Network subnet issues:** +```bash +# Verify devices can reach each other +ping -c 4 member_device_ip +``` + +--- + +## šŸ”§ **Development & Debugging** + +### Enable Detailed Logging + +```go +import "log" + +// Enable verbose HTTP logging +log.SetFlags(log.LstdFlags | log.Lshortfile) + +// Custom HTTP client with debug +transport := &http.Transport{ + // Add debug transport if needed +} + +config := client.ClientConfig{ + Host: "192.168.1.100", + Port: 8090, + Timeout: 10 * time.Second, +} +``` + +### Debug WebSocket Events + +```go +wsClient.OnUnknownEvent(func(event *models.WebSocketEvent) { + log.Printf("Raw event: %+v", event) +}) + +// Enable WebSocket debug logging +config := client.DefaultWebSocketConfig() +config.Logger = &client.DefaultLogger{} // Or custom logger +``` + +### Network Debugging Tools + +```bash +# Capture SoundTouch traffic +sudo tcpdump -i any host 192.168.1.100 and port 8090 + +# Monitor WebSocket traffic +sudo tcpdump -i any host 192.168.1.100 and port 8080 + +# HTTP debugging with curl +curl -v http://192.168.1.100:8090/info +curl -v http://192.168.1.100:8090/volume +``` + +--- + +## šŸ“Š **Performance Issues** + +### High Memory Usage + +**Symptoms:** +- Go process memory keeps growing +- Out of memory errors in long-running apps + +**Solutions:** + +1. **Connection cleanup:** +```go +// Always close WebSocket connections +defer wsClient.Disconnect() + +// Use connection pools for multiple devices +pool := NewConnectionPool(10, 5*time.Minute) +defer pool.Close() +``` + +2. **Goroutine leaks:** +```go +// Use context for cancellation +ctx, cancel := context.WithCancel(context.Background()) +defer cancel() + +// Monitor goroutines +go func() { + for { + fmt.Printf("Goroutines: %d\n", runtime.NumGoroutine()) + time.Sleep(10 * time.Second) + } +}() +``` + +### Slow Response Times + +**Solutions:** + +1. **Increase timeouts appropriately:** +```go +config := client.ClientConfig{ + Timeout: 15 * time.Second, // Reasonable for network ops +} +``` + +2. **Use connection pooling:** +```go +// Reuse connections instead of creating new ones +pool := NewConnectionPool(5, 5*time.Minute) +client := pool.GetClient(host, port) +``` + +3. **Concurrent operations:** +```go +// Process multiple devices concurrently +var wg sync.WaitGroup +for _, client := range clients { + wg.Add(1) + go func(c *client.Client) { + defer wg.Done() + // Process device + }(client) +} +wg.Wait() +``` + +--- + +## 🚨 **Emergency Procedures** + +### Device Becomes Unresponsive + +1. **Power cycle device:** + - Unplug for 10 seconds + - Reconnect and wait 30 seconds for boot + +2. **Network reset:** + - Hold Bluetooth and Volume Down for 10 seconds + - Device will reset network settings + +3. **Factory reset (last resort):** + - Hold Power for 10 seconds while plugged in + - Will lose all presets and settings + +### Multiple Devices Acting Strange + +1. **Check router:** + - Restart router/access point + - Check for firmware updates + - Verify DHCP/IP assignment + +2. **Network interference:** + - Check for 2.4GHz interference + - Try 5GHz WiFi if available + - Check for microwave/Bluetooth interference + +### App Crashes or Hangs + +1. **Graceful shutdown:** +```go +// Always use context for cancellation +ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) +defer cancel() + +// Cleanup resources +defer func() { + if wsClient != nil { + wsClient.Disconnect() + } +}() +``` + +2. **Resource monitoring:** +```go +// Monitor resource usage +go func() { + var m runtime.MemStats + for { + runtime.ReadMemStats(&m) + log.Printf("Alloc = %d KB, Sys = %d KB", m.Alloc/1024, m.Sys/1024) + time.Sleep(30 * time.Second) + } +}() +``` + +--- + +## šŸ“‹ **Diagnostic Checklist** + +Use this checklist to systematically troubleshoot issues: + +### Network Connectivity +- [ ] Device power LED is solid white +- [ ] Both devices on same network subnet +- [ ] Firewall allows ports 8090 (HTTP) and 8080 (WebSocket) +- [ ] Can ping device IP address +- [ ] Can telnet to ports 8090 and 8080 + +### Device Status +- [ ] Device not in setup mode (solid white LED) +- [ ] Recent firmware version (check Bose app) +- [ ] Device responds to Bose app +- [ ] No other apps connected to device + +### Code Configuration +- [ ] Correct IP address and ports +- [ ] Reasonable timeouts (10-30 seconds) +- [ ] Proper error handling +- [ ] Resource cleanup (defer statements) + +### Multiroom Specific +- [ ] All devices support multiroom +- [ ] Device IDs are correct (from GetDeviceInfo) +- [ ] Devices on same network subnet +- [ ] No existing zone conflicts + +--- + +## šŸ›Ÿ **Getting More Help** + +### Information to Gather + +When reporting issues, include: + +```go +// Device information +info, _ := client.GetDeviceInfo() +fmt.Printf("Device: %s %s (ID: %s)\n", info.Type, info.Name, info.DeviceID) + +// Network information +network, _ := client.GetNetworkInfo() +fmt.Printf("Network: %+v\n", network) + +// Go version and OS +fmt.Printf("Go version: %s\n", runtime.Version()) +fmt.Printf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH) +``` + +### Useful Commands + +```bash +# System information +go version +uname -a # Linux/macOS +systeminfo # Windows + +# Network debugging +ip addr show # Linux +ifconfig # macOS +ipconfig /all # Windows + +# SoundTouch specific +go run ./cmd/soundtouch-cli -host -info +go run ./cmd/soundtouch-cli -host -network-info +``` + +### Support Resources + +- **GitHub Issues**: Create detailed issue with logs and system info +- **Documentation**: Check `/docs` directory for specific topics +- **Examples**: Review `/examples` for working code patterns +- **CLI Tool**: Use built-in CLI for testing and debugging + +Remember: Most issues are network-related. Start with basic connectivity testing before investigating code issues. \ No newline at end of file