diff --git a/pkg/client/websocket.go b/pkg/client/websocket.go index 6b4ab3d3..7c855a17 100644 --- a/pkg/client/websocket.go +++ b/pkg/client/websocket.go @@ -236,10 +236,12 @@ func (ws *WebSocketClient) connectWithConfig(config *WebSocketConfig) error { defer ws.connectMu.Unlock() ws.mu.RLock() + if ws.connected { ws.mu.RUnlock() return fmt.Errorf("already connected") } + ctx := ws.ctx dialContext := ws.dialContext ws.mu.RUnlock() @@ -288,20 +290,25 @@ func (ws *WebSocketClient) connectWithConfig(config *WebSocketConfig) error { ws.mu.Lock() if err := ctx.Err(); err != nil { ws.mu.Unlock() + _ = conn.Close() return fmt.Errorf("WebSocket client closed during connect: %w", err) } + if ws.connected { ws.mu.Unlock() + _ = conn.Close() return fmt.Errorf("already connected") } + connection, transportHandler, transportGeneration := ws.activateConnectionLocked(conn) ws.mu.Unlock() notifyTransportState(transportHandler, true, transportGeneration) + go ws.readLoop(config, connection) go ws.pingLoop(config, connection) @@ -349,14 +356,17 @@ func (ws *WebSocketClient) Disconnect() error { ws.mu.Lock() wasConnected := ws.connected + ws.reconnect = false if ws.connection != nil { ws.connection.cancel() ws.connection = nil } + conn := ws.conn ws.conn = nil ws.connected = false + var ( transportHandler func(bool, uint64) transportGeneration uint64 @@ -370,13 +380,16 @@ func (ws *WebSocketClient) Disconnect() error { if conn != nil { err := conn.Close() + ws.logger.Printf("Disconnected") notifyTransportState(transportHandler, false, transportGeneration) return err } + notifyTransportState(transportHandler, false, transportGeneration) + if !wasConnected { return fmt.Errorf("not connected") } @@ -391,14 +404,17 @@ func (ws *WebSocketClient) Close() error { ws.mu.Lock() wasConnected := ws.connected + ws.reconnect = false if ws.connection != nil { ws.connection.cancel() ws.connection = nil } + conn := ws.conn ws.conn = nil ws.connected = false + var ( transportHandler func(bool, uint64) transportGeneration uint64 @@ -417,6 +433,7 @@ func (ws *WebSocketClient) Close() error { } err := conn.Close() + ws.logger.Printf("Disconnected") notifyTransportState(transportHandler, false, transportGeneration) @@ -465,7 +482,9 @@ func (ws *WebSocketClient) readLoop(config *WebSocketConfig, connection *webSock transportGeneration := ws.transportGeneration reconnect := ws.reconnect ws.mu.Unlock() + _ = connection.conn.Close() + notifyTransportState(transportHandler, false, transportGeneration) // Attempt reconnection if enabled diff --git a/pkg/service/soundtouchweb/device_projection.go b/pkg/service/soundtouchweb/device_projection.go index 6167f7a0..0cc2eafa 100644 --- a/pkg/service/soundtouchweb/device_projection.go +++ b/pkg/service/soundtouchweb/device_projection.go @@ -263,14 +263,16 @@ func newStereoPairView(group *models.Group, byDeviceID map[string][]deviceProjec func projectedDeviceInfo(controlID string, info *models.DeviceInfo, pair *stereoPairView) *models.DeviceInfo { if info == nil { - return info + return nil } address := projectedIPAddress(controlID, info, "") + name := info.Name if pair != nil && pair.Name != "" { name = pair.Name } + if address == info.IPAddress && name == info.Name { return info } @@ -301,9 +303,11 @@ func projectedConnectivity(status *webtypes.DeviceStatus) webtypes.Connectivity if status == nil { return webtypes.ConnectivityOffline } + if status.Connectivity != "" { return status.Connectivity } + if status.IsConnected { return webtypes.ConnectivityOnline } diff --git a/pkg/service/soundtouchweb/discovery.go b/pkg/service/soundtouchweb/discovery.go index ce3104da..8ce5b86d 100644 --- a/pkg/service/soundtouchweb/discovery.go +++ b/pkg/service/soundtouchweb/discovery.go @@ -66,10 +66,15 @@ func NewDiscoveryService(discoveryInterface string, configuredHosts ...string) * // mDNS/UPnP. If the host is already known, the existing entry's // LastSeen is bumped and the function returns without re-fetching. func (app *WebApp) AddDeviceByHost(host string, port int, source string) { - app.addDeviceByHost(host, port, source) + app.addDeviceByHost(context.Background(), host, port, source) } -func (app *WebApp) addDeviceByHost(host string, port int, source string) *webtypes.DeviceConnection { +func (app *WebApp) addDeviceByHost( + ctx context.Context, + host string, + port int, + source string, +) *webtypes.DeviceConnection { // Fast path: skip the network call if we already know this host. if app.TouchDevice(host) { return nil @@ -89,10 +94,11 @@ func (app *WebApp) addDeviceByHost(host string, port int, source string) *webtyp // Keep the registry key stable for controls, but expose a canonical numeric // address separately for presentation and sorting. - info.IPAddress = resolvedDeviceIPAddress(host, info) + info.IPAddress = resolvedDeviceIPAddress(ctx, host, info) conn := webtypes.NewDeviceConnection(c, info) conn.MarkHTTPSuccess(time.Now()) + if !app.AddDevice(host, conn) { // Lost a race — another goroutine inserted the same host // between TouchDevice and AddDevice. AddDevice bumped LastSeen @@ -124,7 +130,7 @@ func (app *WebApp) addDeviceByHost(host string, port int, source string) *webtyp return conn } -func resolvedDeviceIPAddress(host string, info *models.DeviceInfo) string { +func resolvedDeviceIPAddress(ctx context.Context, host string, info *models.DeviceInfo) string { if info != nil { if address := numericIPAddress(info.IPAddress); address != "" { return address @@ -142,7 +148,7 @@ func resolvedDeviceIPAddress(host string, info *models.DeviceInfo) string { return address } - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() addresses, err := net.DefaultResolver.LookupNetIP(ctx, "ip4", bareHost) @@ -178,7 +184,7 @@ func numericIPAddress(address string) string { // need to distinguish "read failed" from "converged" (the bounded startup // retry) should call seedExtraDevices directly instead. func (app *WebApp) SeedExtraDevices() { - if _, _, _, err := app.seedExtraDevices(); err != nil { + if _, _, _, err := app.seedExtraDevices(context.Background()); err != nil { log.Printf("SeedExtraDevices: failed to read extra device hosts: %v", err) } } @@ -203,7 +209,9 @@ type seededExtraDevice struct { // A non-nil error means the hook itself failed (e.g. a datastore glitch); // callers must treat that as "unknown state, don't prune, don't declare // ready" rather than as an empty desired set. -func (app *WebApp) seedExtraDevices() (inserted []seededExtraDevice, removed int, desired map[string]struct{}, err error) { +func (app *WebApp) seedExtraDevices( + ctx context.Context, +) (inserted []seededExtraDevice, removed int, desired map[string]struct{}, err error) { if app.ExtraDeviceHosts == nil { return nil, 0, nil, nil } @@ -231,7 +239,7 @@ func (app *WebApp) seedExtraDevices() (inserted []seededExtraDevice, removed int go func(h string) { defer wg.Done() - conn := app.addDeviceByHost(h, 8090, "service-store") + conn := app.addDeviceByHost(ctx, h, 8090, "service-store") if conn == nil { return } @@ -266,7 +274,7 @@ func (app *WebApp) seedExtraDevices() (inserted []seededExtraDevice, removed int // while the service is starting. func (app *WebApp) SeedExtraDevicesUntilReady(ctx context.Context, retryInterval time.Duration) { retryUntilReady(ctx, retryInterval, func() bool { - inserted, removed, desired, err := app.seedExtraDevices() + inserted, removed, desired, err := app.seedExtraDevices(ctx) if err != nil { log.Printf("SeedExtraDevicesUntilReady: failed to read extra device hosts, will retry: %v", err) return false @@ -373,7 +381,9 @@ func (app *WebApp) DiscoverDevices(ctx context.Context, discoveryService *discov // Re-sync from the external device source (embedded: the service datastore). // No-op when ExtraDeviceHosts is unset. - app.SeedExtraDevices() + if _, _, _, err := app.seedExtraDevices(ctx); err != nil { + log.Printf("DiscoverDevices: failed to read extra device hosts: %v", err) + } // Own mDNS/UPnP sweep — standalone only. The embedded build passes a nil // discovery service and relies entirely on the host service's discovery. @@ -394,7 +404,7 @@ func (app *WebApp) DiscoverDevices(ctx context.Context, discoveryService *discov log.Printf("Found %d devices", len(devices)) for _, device := range devices { - app.AddDeviceByHost(device.Host, device.Port, classifySource(device.DiscoveryMethod)) + app.addDeviceByHost(ctx, device.Host, device.Port, classifySource(device.DiscoveryMethod)) } } diff --git a/pkg/service/soundtouchweb/discovery_test.go b/pkg/service/soundtouchweb/discovery_test.go index b798bee0..7cb34204 100644 --- a/pkg/service/soundtouchweb/discovery_test.go +++ b/pkg/service/soundtouchweb/discovery_test.go @@ -107,13 +107,13 @@ func TestResolvedDeviceIPAddressSeparatesHostnameFromReportedAddress(t *testing. {Type: "SCM", IPAddress: "192.0.2.42"}, }} - if got := resolvedDeviceIPAddress("kitchen.local", info); got != "192.0.2.42" { + if got := resolvedDeviceIPAddress(context.Background(), "kitchen.local", info); got != "192.0.2.42" { t.Fatalf("resolved address = %q, want reported speaker address", got) } } func TestResolvedDeviceIPAddressPreservesLiteralIP(t *testing.T) { - if got := resolvedDeviceIPAddress("192.0.2.20", &models.DeviceInfo{}); got != "192.0.2.20" { + if got := resolvedDeviceIPAddress(context.Background(), "192.0.2.20", &models.DeviceInfo{}); got != "192.0.2.20" { t.Fatalf("resolved literal address = %q, want unchanged literal", got) } } diff --git a/pkg/service/soundtouchweb/handler.go b/pkg/service/soundtouchweb/handler.go index fc02da21..aa4eacfe 100644 --- a/pkg/service/soundtouchweb/handler.go +++ b/pkg/service/soundtouchweb/handler.go @@ -465,6 +465,7 @@ func (app *WebApp) HandleDeleteDevice(w http.ResponseWriter, r *http.Request) { return } + app.BroadcastDeviceList() w.Header().Set("Content-Type", "application/json") @@ -1057,6 +1058,7 @@ func (app *WebApp) HandleGetZone(w http.ResponseWriter, r *http.Request) { masterIP := app.findIPByHwID(zone.Master) masterName := "" + if conn, ok := app.GetDevice(masterIP); ok { if info := conn.Info(); info != nil { masterName = info.Name @@ -1073,6 +1075,7 @@ func (app *WebApp) HandleGetZone(w http.ResponseWriter, r *http.Request) { for _, m := range zone.Members { name := "" + if conn, ok := app.GetDevice(m.IP); ok { if info := conn.Info(); info != nil { name = info.Name diff --git a/pkg/service/soundtouchweb/websocket.go b/pkg/service/soundtouchweb/websocket.go index 74a02fe0..394b2440 100644 --- a/pkg/service/soundtouchweb/websocket.go +++ b/pkg/service/soundtouchweb/websocket.go @@ -492,6 +492,7 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device if conn.Client == nil { return } + if !conn.TryStartWebSocketLoop() { return } @@ -605,6 +606,7 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device if !published { return } + if err != nil { log.Printf("Failed to connect WebSocket for device %s: %v (retrying in %s)", sanitizeLog(deviceID), err, backoff) @@ -627,10 +629,6 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device // disconnected would otherwise stay stale until the next WS event. go app.UpdateDeviceStatus(deviceID, conn) - // Reset backoff after a successful connect so the next failure - // starts at the lowest cadence again. - backoff = initialBackoff - <-conn.Done() return @@ -737,6 +735,7 @@ func (app *WebApp) updateDeviceStatus(_ string, conn *webtypes.DeviceConnection, if stereoCapable && groupBaseline == nil { groupGeneration = conn.BeginGroupRefresh() } + nameGeneration := conn.BeginNameRefresh() // Phase 1: slow network fetches. Local vars only, no shared state diff --git a/pkg/service/soundtouchweb/webtypes/types.go b/pkg/service/soundtouchweb/webtypes/types.go index c47cbee5..90df2a8a 100644 --- a/pkg/service/soundtouchweb/webtypes/types.go +++ b/pkg/service/soundtouchweb/webtypes/types.go @@ -126,6 +126,7 @@ type DeviceStatus struct { // evidence but cannot override a current direct-path success. type Connectivity string +// Player connectivity states derived from direct and speaker-reported evidence. const ( ConnectivityOnline Connectivity = "online" ConnectivityStale Connectivity = "stale" @@ -179,6 +180,7 @@ func NewDeviceConnection(c *client.Client, info *models.DeviceInfo) *DeviceConne IsConnected: false, LastActivity: time.Now(), }) + if info != nil { conn.storeDeviceName(info.Name) } @@ -290,6 +292,7 @@ func (c *DeviceConnection) SetWebSocket(ws *client.WebSocketClient) bool { select { case <-c.done: c.webSocketMu.Unlock() + if ws != nil { _ = ws.Close() } @@ -415,6 +418,7 @@ func (c *DeviceConnection) BeginHTTPPoll() uint64 { if c.pollEventGen == nil { c.pollEventGen = make(map[uint64]uint64) } + c.pollEventGen[c.nextPollGeneration] = c.speakerEventGen return c.nextPollGeneration @@ -431,6 +435,7 @@ func (c *DeviceConnection) ApplySpeakerEventAt(at time.Time, mut func(*DeviceSta if mut != nil { mut(status) } + c.applyConnectivityLocked(status, at) }) } @@ -481,10 +486,12 @@ func (c *DeviceConnection) ObserveEventStreamTransport( } c.lastTransportGeneration = generation + c.eventStreamConnected = connected if connected { c.recordDirectSuccessLocked(at) } + c.UpdateStatus(func(status *DeviceStatus) { c.applyConnectivityLocked(status, at) }) @@ -502,6 +509,7 @@ func (c *DeviceConnection) ObserveEventStream(connected bool, at time.Time) { if connected { c.recordDirectSuccessLocked(at) } + c.UpdateStatus(func(status *DeviceStatus) { c.applyConnectivityLocked(status, at) }) @@ -550,7 +558,9 @@ func (c *DeviceConnection) CompleteHTTPPoll( if merge != nil && knownGeneration && pollEventGeneration == c.speakerEventGen { merge(status) } + c.applyConnectivityLocked(status, at) + if success { status.LastActivity = at } @@ -601,6 +611,7 @@ func withinConnectivityGrace(at, success time.Time) bool { if success.IsZero() { return false } + if at.Before(success) { return true }