mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
The speaker WebSocket was cycling every ~65 s because the gorilla pong handler was never set, so the 60-second read deadline in readLoop fired after each ping cycle (30 s interval + 5 s reconnect = ~65 s loop). Setting a pong handler that extends the deadline on every pong response keeps the connection alive indefinitely during quiet periods. After any (re)connect the Go server now immediately fetches current device state via HTTP, because Bose speakers do not replay WebSocket events on new connections — anything that changed during a disconnect window would otherwise stay stale until the next speaker-side event. A 30-second periodic HTTP poll per device is added as a backstop for Spotify Connect track changes that the SoundTouch API does not surface as nowPlayingUpdated WebSocket events. On the browser side, track identity (TrackID / ContentItem.Location) is added to the NowPlaying timer effect deps so the local counter resets whenever the track changes regardless of start position, and the time label is clamped to the song total to prevent "4:17 / 4:09" overruns. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
624 lines
16 KiB
Go
624 lines
16 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 {
|
|
ws.logger.Printf("Received unknown event types: %v", eventTypes)
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|