Files
Tobias GesellchenandClaude Sonnet 4.6 b040c8a90c feat(soundtouch-web): add multi-room zone management
Backend:
- GET /api/zone/{id} — zone info enriched with device names and role flags
- POST /api/zone/{id}/add/{slaveId} — add slave (creates zone if standalone)
- POST /api/zone/{id}/remove/{slaveId} — remove slave from zone
- POST /api/zone/{id}/dissolve — dissolve zone to standalone
- POST /api/zone/{id}/leave — slave leaves its zone (backend finds master)

Frontend (Zone.js):
- Standalone: shows "Group with…" button, opens device picker overlay
- Master: member list with per-row Remove, Add speaker, Dissolve buttons
- Slave: shows master name, Leave zone button
- Lazy-loads on device detail open; refreshes after each zone operation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:22:57 +02:00

320 lines
8.3 KiB
Go

package soundtouchweb
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
)
// HandleWebSocket handles browser WebSocket connections for real-time updates.
func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := app.Upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
defer func() {
app.WSMutex.Lock()
delete(app.WSClients, conn)
app.WSMutex.Unlock()
conn.Close()
}()
app.WSMutex.Lock()
app.WSClients[conn] = true
app.WSMutex.Unlock()
devices := make(map[string]interface{})
for id, device := range app.Devices {
devices[id] = map[string]interface{}{
"info": device.DeviceInfo,
"status": device.Status,
"lastSeen": device.LastSeen,
}
}
if err := conn.WriteJSON(webtypes.WebSocketMessage{Type: "devices", Data: devices}); err != nil {
log.Printf("Failed to send initial data: %v", err)
return
}
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
go func() {
defer conn.Close()
for {
if _, _, err := conn.NextReader(); err != nil {
log.Printf("WebSocket read error: %v", err)
return
}
}
}()
for range ticker.C {
if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
log.Printf("Failed to send ping: %v", err)
return
}
for id, device := range app.Devices {
if device.Status.IsConnected {
if err := conn.WriteJSON(webtypes.WebSocketMessage{
Type: "status_update",
DeviceID: id,
Data: device.Status,
}); err != nil {
log.Printf("Failed to send status update: %v", err)
return
}
}
}
}
}
// HandleAPIDiscover acknowledges a discovery request (actual discovery is triggered by Mount).
func (app *WebApp) HandleAPIDiscover(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
app.sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(webtypes.APIResponse{
Success: true,
Data: map[string]string{"message": "Discovery started"},
}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// ConnectDeviceWebSocket establishes a WebSocket connection to a SoundTouch device.
func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.DeviceConnection) {
if conn.Client == nil {
return
}
wsClient := conn.Client.NewWebSocketClient(nil)
wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) {
conn.Status.NowPlaying = &event.NowPlaying
conn.Status.LastActivity = time.Now()
})
wsClient.OnVolumeUpdated(func(event *models.VolumeUpdatedEvent) {
conn.Status.Volume = &event.Volume
conn.Status.LastActivity = time.Now()
})
wsClient.OnConnectionState(func(event *models.ConnectionStateUpdatedEvent) {
conn.Status.IsConnected = event.ConnectionState.IsConnected()
conn.Status.LastActivity = time.Now()
})
wsClient.OnPresetUpdated(func(event *models.PresetUpdatedEvent) {
conn.Status.Presets = &event.Presets
conn.Status.LastActivity = time.Now()
})
if err := wsClient.Connect(); err != nil {
log.Printf("Failed to connect WebSocket for device %s: %v", deviceID, err)
return
}
conn.WebSocket = wsClient
conn.Status.IsConnected = true
log.Printf("WebSocket connected for device %s", deviceID)
wsClient.Wait()
conn.Status.IsConnected = false
log.Printf("WebSocket disconnected for device %s", deviceID)
}
// UpdateDeviceStatus fetches current status from a device.
func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection) {
if conn.Client == nil {
return
}
statusUpdated := false
if nowPlaying, err := conn.Client.GetNowPlaying(); err == nil {
conn.Status.NowPlaying = nowPlaying
statusUpdated = true
}
if volume, err := conn.Client.GetVolume(); err == nil {
conn.Status.Volume = volume
statusUpdated = true
}
if presets, err := conn.Client.GetPresets(); err == nil {
conn.Status.Presets = presets
statusUpdated = true
}
if statusUpdated {
conn.Status.LastActivity = time.Now()
}
if sources, err := conn.Client.GetSources(); err == nil {
conn.Status.Sources = sources
}
if bass, err := conn.Client.GetBass(); err == nil {
conn.Status.Bass = bass
}
conn.Status.IsConnected = statusUpdated
conn.Status.LastActivity = time.Now()
}
// HandleDeviceWebSocket handles per-device WebSocket connections for real-time device-specific updates.
func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
if deviceID == "" {
http.Error(w, "Device ID required", http.StatusBadRequest)
return
}
device, exists := app.Devices[deviceID]
if !exists {
http.Error(w, "Device not found", http.StatusNotFound)
return
}
conn, err := app.Upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("Device WebSocket upgrade failed for %s: %v", deviceID, err)
return
}
defer conn.Close()
log.Printf("Device WebSocket connected for %s", deviceID)
if err := conn.WriteJSON(webtypes.WebSocketMessage{
Type: "device_status",
DeviceID: deviceID,
Data: map[string]interface{}{"info": device.DeviceInfo, "status": device.Status},
}); err != nil {
log.Printf("Failed to send initial device status: %v", err)
return
}
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
go func() {
defer conn.Close()
for {
if _, _, err := conn.NextReader(); err != nil {
log.Printf("Device WebSocket read error for %s: %v", deviceID, err)
return
}
}
}()
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
log.Printf("Failed to send ping to device WebSocket %s: %v", deviceID, err)
return
}
if err := conn.WriteJSON(webtypes.WebSocketMessage{
Type: "device_status",
DeviceID: deviceID,
Data: map[string]interface{}{"info": device.DeviceInfo, "status": device.Status},
}); err != nil {
log.Printf("Failed to send device status update for %s: %v", deviceID, err)
return
}
if device.WebSocket != nil && device.Status.IsConnected {
if err := conn.WriteJSON(webtypes.WebSocketMessage{
Type: "device_realtime",
DeviceID: deviceID,
Data: map[string]interface{}{
"nowPlaying": device.Status.NowPlaying,
"volume": device.Status.Volume,
"timestamp": time.Now(),
},
}); err != nil {
log.Printf("Failed to send realtime update for %s: %v", deviceID, err)
return
}
}
}
}
// BroadcastDeviceList sends the updated device list to all connected browser WebSocket clients.
func (app *WebApp) BroadcastDeviceList() {
app.WSMutex.RLock()
defer app.WSMutex.RUnlock()
devices := make(map[string]interface{})
for id, device := range app.Devices {
devices[id] = map[string]interface{}{
"info": device.DeviceInfo,
"status": device.Status,
"lastSeen": device.LastSeen,
}
}
app.broadcast(webtypes.WebSocketMessage{Type: "devices", Data: devices})
}
// BroadcastDiscoveryStatus sends discovery progress to all connected browser WebSocket clients.
func (app *WebApp) BroadcastDiscoveryStatus(status string, deviceCount int) {
app.WSMutex.RLock()
defer app.WSMutex.RUnlock()
app.broadcast(webtypes.WebSocketMessage{
Type: "discovery_status",
Data: map[string]interface{}{"status": status, "deviceCount": deviceCount},
})
}
// broadcast sends a message to all registered WS clients, removing failed ones.
// Caller must hold at least a read lock on WSMutex.
func (app *WebApp) broadcast(msg webtypes.WebSocketMessage) {
var failed []*websocket.Conn
for client := range app.WSClients {
if err := client.WriteJSON(msg); err != nil {
log.Printf("Failed to broadcast to WebSocket client: %v", err)
failed = append(failed, client)
}
}
for _, client := range failed {
delete(app.WSClients, client)
client.Close()
}
}