mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-10 12:46:13 +00:00
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
633 lines
17 KiB
Go
633 lines
17 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"log"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gesellix/bose-soundtouch/pkg/models"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// WebSocketClient handles WebSocket connections to SoundTouch devices
|
|
type WebSocketClient struct {
|
|
client *Client
|
|
conn *websocket.Conn
|
|
handlers *models.WebSocketEventHandlers
|
|
mu sync.RWMutex
|
|
writeMu sync.Mutex // serializes all writes; gorilla/websocket allows one concurrent writer
|
|
connected bool
|
|
reconnect bool
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
logger Logger
|
|
bufferSize int
|
|
}
|
|
|
|
// Logger interface for WebSocket logging
|
|
type Logger interface {
|
|
Printf(format string, v ...interface{})
|
|
}
|
|
|
|
// DefaultLogger uses standard log package
|
|
type DefaultLogger struct{}
|
|
|
|
// Printf implements the Logger interface by printing formatted messages with a WebSocket prefix.
|
|
func (d DefaultLogger) Printf(format string, v ...interface{}) {
|
|
log.Printf("[WebSocket] %s", sanitizeLog(fmt.Sprintf(format, v...)))
|
|
}
|
|
|
|
// WebSocketConfig holds configuration for WebSocket client
|
|
type WebSocketConfig struct {
|
|
// ReconnectInterval defines how long to wait between reconnection attempts
|
|
ReconnectInterval time.Duration
|
|
// MaxReconnectAttempts defines maximum number of reconnection attempts (0 = unlimited)
|
|
MaxReconnectAttempts int
|
|
// PingInterval defines how often to send ping messages to keep connection alive
|
|
PingInterval time.Duration
|
|
// PongTimeout defines how long to wait for pong response
|
|
PongTimeout time.Duration
|
|
// ReadBufferSize defines the WebSocket read buffer size
|
|
ReadBufferSize int
|
|
// WriteBufferSize defines the WebSocket write buffer size
|
|
WriteBufferSize int
|
|
// Logger for WebSocket events (nil = default logger)
|
|
Logger Logger
|
|
}
|
|
|
|
// DefaultWebSocketConfig returns a default WebSocket configuration
|
|
func DefaultWebSocketConfig() *WebSocketConfig {
|
|
return &WebSocketConfig{
|
|
ReconnectInterval: 5 * time.Second,
|
|
MaxReconnectAttempts: 0, // Unlimited
|
|
PingInterval: 30 * time.Second,
|
|
PongTimeout: 10 * time.Second,
|
|
ReadBufferSize: 1024,
|
|
WriteBufferSize: 1024,
|
|
Logger: DefaultLogger{},
|
|
}
|
|
}
|
|
|
|
// NewWebSocketClient creates a new WebSocket client for the given SoundTouch client
|
|
func (c *Client) NewWebSocketClient(config *WebSocketConfig) *WebSocketClient {
|
|
if config == nil {
|
|
config = DefaultWebSocketConfig()
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
return &WebSocketClient{
|
|
client: c,
|
|
handlers: &models.WebSocketEventHandlers{},
|
|
reconnect: true,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
logger: config.Logger,
|
|
bufferSize: config.ReadBufferSize,
|
|
}
|
|
}
|
|
|
|
// SetHandlers sets the event handlers for different WebSocket event types
|
|
func (ws *WebSocketClient) SetHandlers(handlers *models.WebSocketEventHandlers) {
|
|
ws.mu.Lock()
|
|
defer ws.mu.Unlock()
|
|
|
|
ws.handlers = handlers
|
|
}
|
|
|
|
// OnNowPlaying sets a handler for now playing events
|
|
func (ws *WebSocketClient) OnNowPlaying(handler models.TypedEventHandler[*models.NowPlayingUpdatedEvent]) {
|
|
ws.mu.Lock()
|
|
defer ws.mu.Unlock()
|
|
|
|
ws.handlers.OnNowPlaying = handler
|
|
}
|
|
|
|
// OnVolumeUpdated sets a handler for volume update events
|
|
func (ws *WebSocketClient) OnVolumeUpdated(handler models.TypedEventHandler[*models.VolumeUpdatedEvent]) {
|
|
ws.mu.Lock()
|
|
defer ws.mu.Unlock()
|
|
|
|
ws.handlers.OnVolumeUpdated = handler
|
|
}
|
|
|
|
// OnConnectionState sets a handler for connection state events
|
|
func (ws *WebSocketClient) OnConnectionState(handler models.TypedEventHandler[*models.ConnectionStateUpdatedEvent]) {
|
|
ws.mu.Lock()
|
|
defer ws.mu.Unlock()
|
|
|
|
ws.handlers.OnConnectionState = handler
|
|
}
|
|
|
|
// OnPresetUpdated sets a handler for preset update events
|
|
func (ws *WebSocketClient) OnPresetUpdated(handler models.TypedEventHandler[*models.PresetUpdatedEvent]) {
|
|
ws.mu.Lock()
|
|
defer ws.mu.Unlock()
|
|
|
|
ws.handlers.OnPresetUpdated = handler
|
|
}
|
|
|
|
// OnZoneUpdated sets a handler for zone update events
|
|
func (ws *WebSocketClient) OnZoneUpdated(handler models.TypedEventHandler[*models.ZoneUpdatedEvent]) {
|
|
ws.mu.Lock()
|
|
defer ws.mu.Unlock()
|
|
|
|
ws.handlers.OnZoneUpdated = handler
|
|
}
|
|
|
|
// OnGroupUpdated sets a handler for ST-10 stereo-pair update events.
|
|
// The device fans these out to both LEFT and RIGHT speakers whenever the
|
|
// pair is created, renamed, or removed, so callers will see one event per
|
|
// affected device.
|
|
func (ws *WebSocketClient) OnGroupUpdated(handler models.TypedEventHandler[*models.GroupUpdatedEvent]) {
|
|
ws.mu.Lock()
|
|
defer ws.mu.Unlock()
|
|
|
|
ws.handlers.OnGroupUpdated = handler
|
|
}
|
|
|
|
// OnBassUpdated sets a handler for bass update events
|
|
func (ws *WebSocketClient) OnBassUpdated(handler models.TypedEventHandler[*models.BassUpdatedEvent]) {
|
|
ws.mu.Lock()
|
|
defer ws.mu.Unlock()
|
|
|
|
ws.handlers.OnBassUpdated = handler
|
|
}
|
|
|
|
// OnUnknownEvent sets a handler for unknown events
|
|
func (ws *WebSocketClient) OnUnknownEvent(handler models.EventHandler) {
|
|
ws.mu.Lock()
|
|
defer ws.mu.Unlock()
|
|
|
|
ws.handlers.OnUnknownEvent = handler
|
|
}
|
|
|
|
// OnRawMessage sets a handler that fires for every incoming frame with
|
|
// the raw bytes and the result of attempting to XML-parse them. The
|
|
// typed handlers (OnNowPlaying, OnGroupUpdated, ...) still run
|
|
// afterwards on successful parses, so OnRawMessage is purely additive —
|
|
// intended for debug/observability tooling.
|
|
func (ws *WebSocketClient) OnRawMessage(handler models.RawMessageHandler) {
|
|
ws.mu.Lock()
|
|
defer ws.mu.Unlock()
|
|
|
|
ws.handlers.OnRawMessage = handler
|
|
}
|
|
|
|
// OnSpecialMessage sets a handler for special (non-updates) messages
|
|
func (ws *WebSocketClient) OnSpecialMessage(handler models.SpecialMessageHandler) {
|
|
ws.mu.Lock()
|
|
defer ws.mu.Unlock()
|
|
|
|
ws.handlers.OnSpecialMessage = handler
|
|
}
|
|
|
|
// Connect establishes a WebSocket connection to the SoundTouch device
|
|
func (ws *WebSocketClient) Connect() error {
|
|
return ws.connectWithConfig(DefaultWebSocketConfig())
|
|
}
|
|
|
|
// ConnectWithConfig establishes a WebSocket connection with custom configuration
|
|
func (ws *WebSocketClient) ConnectWithConfig(config *WebSocketConfig) error {
|
|
return ws.connectWithConfig(config)
|
|
}
|
|
|
|
func (ws *WebSocketClient) connectWithConfig(config *WebSocketConfig) error {
|
|
ws.mu.Lock()
|
|
defer ws.mu.Unlock()
|
|
|
|
if ws.connected {
|
|
return fmt.Errorf("already connected")
|
|
}
|
|
|
|
// Build WebSocket URL
|
|
// Parse the base URL to extract just the hostname
|
|
baseURL, err := url.Parse(ws.client.BaseURL())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse base URL: %w", err)
|
|
}
|
|
|
|
wsURL := url.URL{
|
|
Scheme: "ws",
|
|
Host: fmt.Sprintf("%s:8080", baseURL.Hostname()), // SoundTouch WebSocket port is typically 8080
|
|
Path: "/",
|
|
}
|
|
|
|
ws.logger.Printf("Connecting to %s", sanitizeLog(wsURL.String()))
|
|
|
|
// Create dialer with custom buffer sizes and "gabbo" protocol
|
|
dialer := websocket.Dialer{
|
|
HandshakeTimeout: 10 * time.Second,
|
|
ReadBufferSize: config.ReadBufferSize,
|
|
WriteBufferSize: config.WriteBufferSize,
|
|
Subprotocols: []string{"gabbo"}, // Required by SoundTouch API
|
|
}
|
|
|
|
// Establish connection
|
|
conn, resp, err := dialer.DialContext(ws.ctx, wsURL.String(), nil)
|
|
if resp != nil && resp.Body != nil {
|
|
defer func() { _ = resp.Body.Close() }()
|
|
}
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to connect to WebSocket: %w", err)
|
|
}
|
|
|
|
ws.conn = conn
|
|
ws.connected = true
|
|
|
|
// Extend the read deadline on every pong so the connection survives
|
|
// quiet periods between speaker events. Without this, the 60-second
|
|
// read deadline in readLoop fires reliably after one ping cycle (30 s
|
|
// ping interval + 5 s reconnect = ~65 s disconnect loop).
|
|
conn.SetPongHandler(func(string) error {
|
|
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
return nil
|
|
})
|
|
|
|
// Start background goroutines for connection management
|
|
go ws.readLoop(config)
|
|
go ws.pingLoop(config)
|
|
|
|
ws.logger.Printf("Connected to %s", sanitizeLog(wsURL.String()))
|
|
|
|
return nil
|
|
}
|
|
|
|
// Disconnect closes the WebSocket connection
|
|
func (ws *WebSocketClient) Disconnect() error {
|
|
ws.mu.Lock()
|
|
defer ws.mu.Unlock()
|
|
|
|
if !ws.connected {
|
|
return fmt.Errorf("not connected")
|
|
}
|
|
|
|
ws.reconnect = false
|
|
ws.cancel() // Cancel context to stop goroutines
|
|
|
|
if ws.conn != nil {
|
|
err := ws.conn.Close()
|
|
ws.conn = nil
|
|
ws.connected = false
|
|
ws.logger.Printf("Disconnected")
|
|
|
|
return err
|
|
}
|
|
|
|
ws.connected = false
|
|
|
|
return nil
|
|
}
|
|
|
|
// IsConnected returns true if the WebSocket is connected
|
|
func (ws *WebSocketClient) IsConnected() bool {
|
|
ws.mu.RLock()
|
|
defer ws.mu.RUnlock()
|
|
|
|
return ws.connected
|
|
}
|
|
|
|
// readLoop continuously reads messages from the WebSocket connection
|
|
func (ws *WebSocketClient) readLoop(config *WebSocketConfig) {
|
|
defer func() {
|
|
ws.mu.Lock()
|
|
|
|
ws.connected = false
|
|
if ws.conn != nil {
|
|
_ = ws.conn.Close()
|
|
ws.conn = nil
|
|
}
|
|
|
|
ws.mu.Unlock()
|
|
|
|
// Attempt reconnection if enabled
|
|
if ws.reconnect {
|
|
go ws.attemptReconnect(config)
|
|
}
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case <-ws.ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
ws.mu.RLock()
|
|
conn := ws.conn
|
|
ws.mu.RUnlock()
|
|
|
|
if conn == nil {
|
|
return
|
|
}
|
|
|
|
// Set read deadline
|
|
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
|
|
// Read message
|
|
messageType, data, err := conn.ReadMessage()
|
|
if err != nil {
|
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
|
ws.logger.Printf("WebSocket read error: %v", err)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// Only process text messages
|
|
if messageType != websocket.TextMessage {
|
|
continue
|
|
}
|
|
|
|
// Parse and handle the event
|
|
ws.handleMessage(data)
|
|
}
|
|
}
|
|
|
|
// pingLoop sends periodic ping messages to keep the connection alive
|
|
func (ws *WebSocketClient) pingLoop(config *WebSocketConfig) {
|
|
ticker := time.NewTicker(config.PingInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ws.ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
ws.mu.RLock()
|
|
conn := ws.conn
|
|
connected := ws.connected
|
|
ws.mu.RUnlock()
|
|
|
|
if !connected || conn == nil {
|
|
return
|
|
}
|
|
|
|
// Set write deadline for ping
|
|
ws.writeMu.Lock()
|
|
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
|
err := conn.WriteMessage(websocket.PingMessage, nil)
|
|
ws.writeMu.Unlock()
|
|
|
|
if err != nil {
|
|
ws.logger.Printf("Failed to send ping: %v", err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// attemptReconnect attempts to reconnect to the WebSocket
|
|
func (ws *WebSocketClient) attemptReconnect(config *WebSocketConfig) {
|
|
attempt := 0
|
|
for ws.reconnect && (config.MaxReconnectAttempts == 0 || attempt < config.MaxReconnectAttempts) {
|
|
select {
|
|
case <-ws.ctx.Done():
|
|
return
|
|
case <-time.After(config.ReconnectInterval):
|
|
}
|
|
|
|
attempt++
|
|
ws.logger.Printf("Reconnection attempt %d", attempt)
|
|
|
|
if err := ws.connectWithConfig(config); err != nil {
|
|
ws.logger.Printf("Reconnection attempt %d failed: %v", attempt, err)
|
|
continue
|
|
}
|
|
|
|
ws.logger.Printf("Reconnected successfully")
|
|
|
|
return
|
|
}
|
|
|
|
ws.logger.Printf("Max reconnection attempts reached or reconnection disabled")
|
|
}
|
|
|
|
// handleMessage processes incoming WebSocket messages
|
|
func (ws *WebSocketClient) handleMessage(data []byte) {
|
|
// Special (non-updates) messages take their own decode path and
|
|
// surface raw payloads to the OnRawMessage hook from there, so
|
|
// observers see exactly one notification per frame.
|
|
if !ws.isUpdatesMessage(data) {
|
|
ws.handleSpecialMessage(data)
|
|
return
|
|
}
|
|
|
|
event, parseErr := models.ParseWebSocketEvent(data)
|
|
|
|
ws.fireRawMessage(data, parseErr)
|
|
|
|
if parseErr != nil {
|
|
ws.logger.Printf("Failed to parse WebSocket message: %v", parseErr)
|
|
return
|
|
}
|
|
|
|
ws.handleEvent(event)
|
|
}
|
|
|
|
// fireRawMessage invokes the OnRawMessage hook if one is registered.
|
|
// Kept separate so the read path doesn't have to repeat the locking
|
|
// dance for every frame.
|
|
func (ws *WebSocketClient) fireRawMessage(data []byte, parseErr error) {
|
|
ws.mu.RLock()
|
|
handler := ws.handlers.OnRawMessage
|
|
ws.mu.RUnlock()
|
|
|
|
if handler != nil {
|
|
handler(data, parseErr)
|
|
}
|
|
}
|
|
|
|
// handleSpecialMessage processes special (non-updates) WebSocket messages
|
|
func (ws *WebSocketClient) handleSpecialMessage(data []byte) {
|
|
specialMessage, err := models.ParseSpecialMessage(data)
|
|
|
|
ws.fireRawMessage(data, err)
|
|
|
|
if err != nil {
|
|
ws.logger.Printf("Unknown special message type: %s", sanitizeErr(err))
|
|
ws.logger.Printf("Raw message: %s", sanitizeLog(string(data)))
|
|
|
|
return
|
|
}
|
|
|
|
// Call handler if set
|
|
ws.mu.RLock()
|
|
handler := ws.handlers.OnSpecialMessage
|
|
ws.mu.RUnlock()
|
|
|
|
if handler != nil {
|
|
handler(specialMessage)
|
|
}
|
|
}
|
|
|
|
// isUpdatesMessage checks if the message contains an <updates> element
|
|
func (ws *WebSocketClient) isUpdatesMessage(data []byte) bool {
|
|
// Simple check for <updates> element - this avoids full XML parsing
|
|
// for messages we want to ignore like <SoundTouchSdkInfo>
|
|
dataStr := string(data)
|
|
return strings.Contains(dataStr, "<updates") && strings.Contains(dataStr, "deviceID=")
|
|
}
|
|
|
|
func (ws *WebSocketClient) dispatchTypedEvent(handlers *models.WebSocketEventHandlers, eventType models.WebSocketEventType, event *models.WebSocketEvent) bool {
|
|
switch eventType {
|
|
case models.EventTypeNowPlaying:
|
|
if handlers.OnNowPlaying != nil && event.NowPlayingUpdated != nil {
|
|
handlers.OnNowPlaying(event.NowPlayingUpdated)
|
|
}
|
|
|
|
return true
|
|
|
|
case models.EventTypeVolumeUpdated:
|
|
if handlers.OnVolumeUpdated != nil && event.VolumeUpdated != nil {
|
|
handlers.OnVolumeUpdated(event.VolumeUpdated)
|
|
}
|
|
|
|
return true
|
|
|
|
case models.EventTypeConnectionState:
|
|
if handlers.OnConnectionState != nil && event.ConnectionStateUpdated != nil {
|
|
handlers.OnConnectionState(event.ConnectionStateUpdated)
|
|
}
|
|
|
|
return true
|
|
|
|
case models.EventTypePresetUpdated:
|
|
if handlers.OnPresetUpdated != nil && event.PresetUpdated != nil {
|
|
handlers.OnPresetUpdated(event.PresetUpdated)
|
|
}
|
|
|
|
return true
|
|
|
|
default:
|
|
return ws.dispatchTypedEventContinued(handlers, eventType, event)
|
|
}
|
|
}
|
|
|
|
func (ws *WebSocketClient) dispatchTypedEventContinued(handlers *models.WebSocketEventHandlers, eventType models.WebSocketEventType, event *models.WebSocketEvent) bool {
|
|
switch eventType {
|
|
case models.EventTypeZoneUpdated:
|
|
if handlers.OnZoneUpdated != nil && event.ZoneUpdated != nil {
|
|
handlers.OnZoneUpdated(event.ZoneUpdated)
|
|
}
|
|
|
|
return true
|
|
|
|
case models.EventTypeGroupUpdated:
|
|
if handlers.OnGroupUpdated != nil && event.GroupUpdated != nil {
|
|
handlers.OnGroupUpdated(event.GroupUpdated)
|
|
}
|
|
|
|
return true
|
|
|
|
case models.EventTypeBassUpdated:
|
|
if handlers.OnBassUpdated != nil && event.BassUpdated != nil {
|
|
handlers.OnBassUpdated(event.BassUpdated)
|
|
}
|
|
|
|
return true
|
|
|
|
case models.EventTypeRecentsUpdated:
|
|
return true
|
|
|
|
case models.EventTypeLanguageUpdated:
|
|
return true
|
|
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// handleEvent dispatches events to appropriate handlers
|
|
func (ws *WebSocketClient) handleEvent(event *models.WebSocketEvent) {
|
|
ws.mu.RLock()
|
|
handlers := ws.handlers
|
|
ws.mu.RUnlock()
|
|
|
|
eventTypes := event.GetEventTypes()
|
|
hasKnownEvent := false
|
|
|
|
for _, eventType := range eventTypes {
|
|
if ws.dispatchTypedEvent(handlers, eventType, event) {
|
|
hasKnownEvent = true
|
|
}
|
|
}
|
|
|
|
// Handle unknown events
|
|
if !hasKnownEvent && handlers.OnUnknownEvent != nil {
|
|
handlers.OnUnknownEvent(event)
|
|
} else if !hasKnownEvent {
|
|
// Log the actual unmodeled element names (e.g. nowSelectionUpdated)
|
|
// rather than an empty list; skip frames that carry no child events.
|
|
if names := event.UnknownEventNames(); len(names) > 0 {
|
|
sanitizedNames := make([]string, 0, len(names))
|
|
for _, name := range names {
|
|
sanitizedNames = append(sanitizedNames, sanitizeLog(name))
|
|
}
|
|
|
|
ws.logger.Printf("Received unhandled event types: %v", sanitizedNames)
|
|
}
|
|
}
|
|
}
|
|
|
|
// SendMessage sends a message to the WebSocket (if needed for future functionality)
|
|
func (ws *WebSocketClient) SendMessage(message []byte) error {
|
|
ws.mu.RLock()
|
|
conn := ws.conn
|
|
connected := ws.connected
|
|
ws.mu.RUnlock()
|
|
|
|
if !connected || conn == nil {
|
|
return fmt.Errorf("not connected")
|
|
}
|
|
|
|
ws.writeMu.Lock()
|
|
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
|
err := conn.WriteMessage(websocket.TextMessage, message)
|
|
ws.writeMu.Unlock()
|
|
|
|
return err
|
|
}
|
|
|
|
// PairWithAccount sends a request to pair the device with a specific account
|
|
func (ws *WebSocketClient) PairWithAccount(accountID, userAuthToken string) error {
|
|
request := models.PairDeviceWithAccount{
|
|
AccountID: accountID,
|
|
UserAuthToken: userAuthToken,
|
|
}
|
|
|
|
data, err := xml.Marshal(request)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal pairing request: %w", err)
|
|
}
|
|
|
|
ws.logger.Printf("Sending PairDeviceWithAccount for account %s", sanitizeLog(accountID))
|
|
|
|
return ws.SendMessage(data)
|
|
}
|
|
|
|
// UnPairFromAccount sends a request to unpair the device from its account
|
|
func (ws *WebSocketClient) UnPairFromAccount() error {
|
|
request := models.UnPairDeviceWithAccount{}
|
|
|
|
data, err := xml.Marshal(request)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal unpairing request: %w", err)
|
|
}
|
|
|
|
ws.logger.Printf("Sending UnPairDeviceWithAccount")
|
|
|
|
return ws.SendMessage(data)
|
|
}
|
|
|
|
// Wait blocks until the WebSocket connection is closed or context is cancelled
|
|
func (ws *WebSocketClient) Wait() {
|
|
<-ws.ctx.Done()
|
|
}
|