mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-21 01:56:15 +00:00
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>
320 lines
8.3 KiB
Go
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()
|
|
}
|
|
}
|