From b01ab1e1bb6562d57a9fda0731d5bb0cc86ab86a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Lipinsk=C3=BD?= <6032558+Mr-Tao@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:09:01 +0200 Subject: [PATCH] fix(player): project stereo pairs as logical devices --- .../soundtouchweb/device_projection.go | 260 ++++++++++++++++++ .../soundtouchweb/device_projection_test.go | 181 ++++++++++++ pkg/service/soundtouchweb/handler.go | 26 +- pkg/service/soundtouchweb/static/css/app.css | 2 + pkg/service/soundtouchweb/static/js/app.js | 7 + .../static/js/components/DeviceList.js | 10 +- .../static/js/components/Library.js | 6 +- pkg/service/soundtouchweb/websocket.go | 59 ++-- pkg/service/soundtouchweb/websocket_test.go | 141 ++++++++++ .../soundtouchweb/webtypes/status_test.go | 79 ++++++ pkg/service/soundtouchweb/webtypes/types.go | 73 ++++- 11 files changed, 780 insertions(+), 64 deletions(-) create mode 100644 pkg/service/soundtouchweb/device_projection.go create mode 100644 pkg/service/soundtouchweb/device_projection_test.go create mode 100644 pkg/service/soundtouchweb/websocket_test.go diff --git a/pkg/service/soundtouchweb/device_projection.go b/pkg/service/soundtouchweb/device_projection.go new file mode 100644 index 00000000..213a3b42 --- /dev/null +++ b/pkg/service/soundtouchweb/device_projection.go @@ -0,0 +1,260 @@ +package soundtouchweb + +import ( + "strings" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes" +) + +// deviceView is the player-facing representation of one control target. +// A stereo pair is projected as one target keyed by its master speaker's host; +// the underlying registry continues to track both physical speakers. +type deviceView struct { + Info *models.DeviceInfo `json:"info"` + Status *webtypes.DeviceStatus `json:"status"` + LastSeen time.Time `json:"lastSeen"` + StereoPair *stereoPairView `json:"stereoPair,omitempty"` +} + +// stereoPairView describes the physical members represented by a logical +// player target. Controls are always sent to MasterDeviceID via the map key. +type stereoPairView struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + MasterDeviceID string `json:"masterDeviceId"` + Status string `json:"status,omitempty"` + MemberCount int `json:"memberCount"` + AvailableMemberCount int `json:"availableMemberCount"` + Degraded bool `json:"degraded"` + Members []stereoPairMemberView `json:"members"` +} + +// stereoPairMemberView is the player-facing role and availability of one +// physical speaker in a stereo pair. +type stereoPairMemberView struct { + DeviceID string `json:"deviceId"` + Role string `json:"role"` + IPAddress string `json:"ipAddress,omitempty"` + Name string `json:"name,omitempty"` + Available bool `json:"available"` +} + +// deviceViewSnapshot projects the physical registry into logical control +// targets for the HTTP API and the global player WebSocket. +func (app *WebApp) deviceViewSnapshot() map[string]deviceView { + return projectDeviceEntries(app.DeviceSnapshot()) +} + +func projectDeviceEntries(snapshot []DeviceEntry) map[string]deviceView { + byDeviceID := make(map[string][]DeviceEntry, len(snapshot)) + for _, entry := range snapshot { + if entry.Device == nil || entry.Device.DeviceInfo == nil { + continue + } + + deviceID := strings.TrimSpace(entry.Device.DeviceInfo.DeviceID) + if deviceID != "" { + byDeviceID[deviceID] = append(byDeviceID[deviceID], entry) + } + } + + masters := make(map[string]*stereoPairView) + hidden := make(map[string]bool) + + for _, entry := range snapshot { + if entry.Device == nil || entry.Device.DeviceInfo == nil { + continue + } + + status := entry.Device.Status() + if status == nil || !validMasterGroup(entry.Device.DeviceInfo.DeviceID, status.Group) { + continue + } + + master, unique := uniqueDeviceEntry(byDeviceID, status.Group.MasterDeviceID) + if !unique || master.ID != entry.ID || !registeredMembersAgree(status.Group, byDeviceID) { + continue + } + + pair := newStereoPairView(status.Group, byDeviceID) + masters[entry.ID] = pair + + for _, role := range status.Group.Roles.Roles { + member, ok := uniqueDeviceEntry(byDeviceID, role.DeviceID) + if ok && member.ID != entry.ID { + hidden[member.ID] = true + } + } + } + + devices := make(map[string]deviceView, len(snapshot)) + for _, entry := range snapshot { + if entry.Device == nil || hidden[entry.ID] { + continue + } + + pair := masters[entry.ID] + devices[entry.ID] = deviceView{ + Info: projectedDeviceInfo(entry.Device.DeviceInfo, pair), + Status: entry.Device.Status(), + LastSeen: entry.Device.LastSeen, + StereoPair: pair, + } + } + + return devices +} + +func validMasterGroup(deviceID string, group *models.Group) bool { + if group == nil || group.IsEmpty() || strings.TrimSpace(group.ID) == "" || + strings.TrimSpace(group.MasterDeviceID) == "" || len(group.Roles.Roles) != 2 || + strings.TrimSpace(deviceID) != strings.TrimSpace(group.MasterDeviceID) { + return false + } + + seenDevices := make(map[string]bool, len(group.Roles.Roles)) + seenRoles := make(map[string]bool, len(group.Roles.Roles)) + masterPresent := false + + for _, role := range group.Roles.Roles { + memberID := strings.TrimSpace(role.DeviceID) + memberRole := strings.ToUpper(strings.TrimSpace(role.Role)) + if memberID == "" || seenDevices[memberID] || (memberRole != "LEFT" && memberRole != "RIGHT") || seenRoles[memberRole] { + return false + } + + seenDevices[memberID] = true + seenRoles[memberRole] = true + masterPresent = masterPresent || memberID == strings.TrimSpace(group.MasterDeviceID) + } + + return masterPresent && seenRoles["LEFT"] && seenRoles["RIGHT"] +} + +func uniqueDeviceEntry(byDeviceID map[string][]DeviceEntry, deviceID string) (DeviceEntry, bool) { + entries := byDeviceID[strings.TrimSpace(deviceID)] + if len(entries) != 1 { + return DeviceEntry{}, false + } + + return entries[0], true +} + +func registeredMembersAgree(group *models.Group, byDeviceID map[string][]DeviceEntry) bool { + for _, role := range group.Roles.Roles { + entries := byDeviceID[strings.TrimSpace(role.DeviceID)] + if len(entries) > 1 { + return false + } + + if len(entries) == 0 { + continue + } + + status := entries[0].Device.Status() + if status == nil || !sameGroupClaim(group, status.Group) { + return false + } + } + + return true +} + +func sameGroupClaim(left, right *models.Group) bool { + if left == nil || right == nil || left.ID != right.ID || left.MasterDeviceID != right.MasterDeviceID || + len(left.Roles.Roles) != len(right.Roles.Roles) { + return false + } + + rightRoles := make(map[string]string, len(right.Roles.Roles)) + for _, role := range right.Roles.Roles { + rightRoles[strings.TrimSpace(role.DeviceID)] = strings.ToUpper(strings.TrimSpace(role.Role)) + } + + for _, role := range left.Roles.Roles { + if rightRoles[strings.TrimSpace(role.DeviceID)] != strings.ToUpper(strings.TrimSpace(role.Role)) { + return false + } + } + + return true +} + +func newStereoPairView(group *models.Group, byDeviceID map[string][]DeviceEntry) *stereoPairView { + members := make([]stereoPairMemberView, 0, len(group.Roles.Roles)) + available := 0 + + for _, role := range group.Roles.Roles { + member := stereoPairMemberView{ + DeviceID: role.DeviceID, + Role: role.Role, + IPAddress: role.IPAddress, + } + + if entry, ok := uniqueDeviceEntry(byDeviceID, role.DeviceID); ok && entry.Device != nil { + if entry.Device.DeviceInfo != nil { + member.Name = entry.Device.DeviceInfo.Name + if entry.Device.DeviceInfo.IPAddress != "" { + member.IPAddress = entry.Device.DeviceInfo.IPAddress + } + } + + status := entry.Device.Status() + member.Available = status != nil && status.IsConnected + if member.Available { + available++ + } + } + + members = append(members, member) + } + + return &stereoPairView{ + ID: group.ID, + Name: logicalPairName(group.Name, members), + MasterDeviceID: group.MasterDeviceID, + Status: group.Status, + MemberCount: len(members), + AvailableMemberCount: available, + Degraded: available != len(members) || (group.Status != "" && group.Status != "GROUP_OK"), + Members: members, + } +} + +func projectedDeviceInfo(info *models.DeviceInfo, pair *stereoPairView) *models.DeviceInfo { + if info == nil || pair == nil || pair.Name == "" || pair.Name == info.Name { + return info + } + + projected := *info + projected.Name = pair.Name + + return &projected +} + +func logicalPairName(groupName string, members []stereoPairMemberView) string { + commonName := "" + for _, member := range members { + name := strings.TrimSpace(member.Name) + if name == "" { + return groupName + } + + if commonName == "" { + commonName = name + continue + } + + if !strings.EqualFold(commonName, name) { + return groupName + } + } + + if commonName != "" { + return commonName + } + + return groupName +} diff --git a/pkg/service/soundtouchweb/device_projection_test.go b/pkg/service/soundtouchweb/device_projection_test.go new file mode 100644 index 00000000..39920a8d --- /dev/null +++ b/pkg/service/soundtouchweb/device_projection_test.go @@ -0,0 +1,181 @@ +package soundtouchweb + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes" +) + +func projectionDevice(host, deviceID, name string, connected bool, group *models.Group) DeviceEntry { + conn := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{ + DeviceID: deviceID, + Name: name, + IPAddress: host, + }) + conn.SetStatus(&webtypes.DeviceStatus{IsConnected: connected, Group: group}) + + return DeviceEntry{ID: host, Device: conn} +} + +func testStereoGroup() *models.Group { + return &models.Group{ + ID: "pair-1", + Name: "Living Room + Living Room", + MasterDeviceID: "left-id", + Status: "GROUP_OK", + Roles: models.GroupRoles{Roles: []models.GroupRole{ + {DeviceID: "left-id", Role: "LEFT", IPAddress: "192.0.2.10"}, + {DeviceID: "right-id", Role: "RIGHT", IPAddress: "192.0.2.11"}, + }}, + } +} + +func TestProjectDeviceEntriesCollapsesStereoPairUnderMaster(t *testing.T) { + group := testStereoGroup() + got := projectDeviceEntries([]DeviceEntry{ + projectionDevice("192.0.2.10", "left-id", "Living Room", true, group), + projectionDevice("192.0.2.11", "right-id", "Living Room", true, group), + }) + + if len(got) != 1 { + t.Fatalf("projected devices = %d, want one logical stereo target: %+v", len(got), got) + } + + master, ok := got["192.0.2.10"] + if !ok { + t.Fatalf("master control target missing: %+v", got) + } + + if master.StereoPair == nil { + t.Fatal("master is missing stereo-pair metadata") + } + + if master.StereoPair.MemberCount != 2 || master.StereoPair.AvailableMemberCount != 2 || master.StereoPair.Degraded { + t.Errorf("unexpected pair availability: %+v", master.StereoPair) + } + + if master.Info.Name != "Living Room" || master.StereoPair.Name != "Living Room" { + t.Errorf("logical pair name was not projected consistently: %+v", master) + } + + if _, ok := got["192.0.2.11"]; ok { + t.Error("physical right member must not be a second control target") + } +} + +func TestProjectDeviceEntriesShowsDegradedPairWhenMemberIsMissing(t *testing.T) { + got := projectDeviceEntries([]DeviceEntry{ + projectionDevice("192.0.2.10", "left-id", "Living Room", true, testStereoGroup()), + }) + + pair := got["192.0.2.10"].StereoPair + if pair == nil { + t.Fatal("connected master should remain a logical pair when its member is unavailable") + } + + if pair.AvailableMemberCount != 1 || !pair.Degraded { + t.Errorf("missing member not reflected as degraded: %+v", pair) + } +} + +func TestProjectDeviceEntriesKeepsStablePairWhenMasterIsDisconnected(t *testing.T) { + group := testStereoGroup() + got := projectDeviceEntries([]DeviceEntry{ + projectionDevice("192.0.2.10", "left-id", "Living Room", false, group), + projectionDevice("192.0.2.11", "right-id", "Living Room", true, group), + }) + + if len(got) != 1 { + t.Fatalf("projected devices = %d, want a stable logical pair while its master is registered", len(got)) + } + + pair := got["192.0.2.10"].StereoPair + if pair == nil || !pair.Degraded || pair.AvailableMemberCount != 1 { + t.Errorf("disconnected master should produce a degraded logical pair: %+v", got) + } +} + +func TestProjectDeviceEntriesLeavesMemberPhysicalWhenMasterIsAbsent(t *testing.T) { + got := projectDeviceEntries([]DeviceEntry{ + projectionDevice("192.0.2.11", "right-id", "Living Room", true, testStereoGroup()), + }) + + if len(got) != 1 || got["192.0.2.11"].StereoPair != nil { + t.Fatalf("member without a registered master must remain a physical target: %+v", got) + } +} + +func TestProjectDeviceEntriesRequiresMasterReportedGroup(t *testing.T) { + group := testStereoGroup() + got := projectDeviceEntries([]DeviceEntry{ + projectionDevice("192.0.2.10", "left-id", "Living Room", true, nil), + projectionDevice("192.0.2.11", "right-id", "Living Room", true, group), + }) + + if len(got) != 2 { + t.Fatalf("slave-only group data must not collapse the registry: %+v", got) + } +} + +func TestProjectDeviceEntriesRejectsMalformedGroup(t *testing.T) { + group := testStereoGroup() + group.Roles.Roles[1].DeviceID = group.Roles.Roles[0].DeviceID + + got := projectDeviceEntries([]DeviceEntry{ + projectionDevice("192.0.2.10", "left-id", "Living Room", true, group), + projectionDevice("192.0.2.11", "right-id", "Living Room", true, group), + }) + + if len(got) != 2 { + t.Fatalf("malformed pair must not hide a physical device: %+v", got) + } +} + +func TestProjectDeviceEntriesRejectsConflictingMemberClaim(t *testing.T) { + masterGroup := testStereoGroup() + memberGroup := testStereoGroup() + memberGroup.ID = "different-pair" + + got := projectDeviceEntries([]DeviceEntry{ + projectionDevice("192.0.2.10", "left-id", "Living Room", true, masterGroup), + projectionDevice("192.0.2.11", "right-id", "Living Room", true, memberGroup), + }) + + if len(got) != 2 { + t.Fatalf("conflicting pair claims must fail open: %+v", got) + } +} + +func TestHandleAPIDevicesUsesLogicalStereoProjection(t *testing.T) { + app := NewWebApp() + group := testStereoGroup() + for _, entry := range []DeviceEntry{ + projectionDevice("192.0.2.10", "left-id", "Living Room", true, group), + projectionDevice("192.0.2.11", "right-id", "Living Room", true, group), + } { + app.AddDevice(entry.ID, entry.Device) + } + + response := httptest.NewRecorder() + app.HandleAPIDevices(response, httptest.NewRequest("GET", "/api/control/devices", nil)) + + var payload struct { + Success bool `json:"success"` + Data map[string]deviceView `json:"data"` + } + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatalf("decode devices response: %v", err) + } + + if response.Code != http.StatusOK || !payload.Success || len(payload.Data) != 1 { + t.Fatalf("unexpected devices response: status=%d payload=%+v", response.Code, payload) + } + + if pair := payload.Data["192.0.2.10"].StereoPair; pair == nil || pair.ID != "pair-1" || pair.MemberCount != 2 { + t.Fatalf("logical stereo metadata missing from devices API: %+v", payload.Data) + } +} diff --git a/pkg/service/soundtouchweb/handler.go b/pkg/service/soundtouchweb/handler.go index 3591ef66..34401063 100644 --- a/pkg/service/soundtouchweb/handler.go +++ b/pkg/service/soundtouchweb/handler.go @@ -251,20 +251,9 @@ func (app *WebApp) HandleAPIDevices(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") // Return all devices as JSON - snapshot := app.DeviceSnapshot() - devices := make(map[string]interface{}, len(snapshot)) - - for _, entry := range snapshot { - devices[entry.ID] = map[string]interface{}{ - "info": entry.Device.DeviceInfo, - "status": entry.Device.Status(), - "lastSeen": entry.Device.LastSeen, - } - } - response := webtypes.APIResponse{ Success: true, - Data: devices, + Data: app.deviceViewSnapshot(), } if err := json.NewEncoder(w).Encode(response); err != nil { @@ -734,20 +723,9 @@ func (app *WebApp) BroadcastDeviceList() { app.WSMutex.RLock() defer app.WSMutex.RUnlock() - snapshot := app.DeviceSnapshot() - devices := make(map[string]interface{}, len(snapshot)) - - for _, entry := range snapshot { - devices[entry.ID] = map[string]interface{}{ - "info": entry.Device.DeviceInfo, - "status": entry.Device.Status(), - "lastSeen": entry.Device.LastSeen, - } - } - message := webtypes.WebSocketMessage{ Type: "devices", - Data: devices, + Data: app.deviceViewSnapshot(), } // Send to all connected clients diff --git a/pkg/service/soundtouchweb/static/css/app.css b/pkg/service/soundtouchweb/static/css/app.css index dec52dc7..dc179a2e 100644 --- a/pkg/service/soundtouchweb/static/css/app.css +++ b/pkg/service/soundtouchweb/static/css/app.css @@ -399,6 +399,8 @@ img { display: block; max-width: 100%; } } .device-type { font-size: .8rem; color: var(--text-dim); margin-bottom: .5rem; display: flex; gap: .4rem; flex-wrap: wrap; } .device-ip { color: var(--text); font-family: monospace; font-weight: 500; } +.stereo-pair-state { color: var(--accent); font-weight: 600; } +.stereo-pair-state.degraded { color: var(--offline); } .device-indicator { width: 8px; height: 8px; border-radius: 50%; diff --git a/pkg/service/soundtouchweb/static/js/app.js b/pkg/service/soundtouchweb/static/js/app.js index 5eb871c5..2e31cd6f 100644 --- a/pkg/service/soundtouchweb/static/js/app.js +++ b/pkg/service/soundtouchweb/static/js/app.js @@ -138,6 +138,13 @@ function App() { }; }, []); + useEffect(() => { + if (selectedId && !devices[selectedId]) { + setSelectedId(null); + if (page === 'device') setPage('devices'); + } + }, [devices, selectedId, page]); + function showToast(msg) { setToast(null); setTimeout(() => setToast(msg), 10); diff --git a/pkg/service/soundtouchweb/static/js/components/DeviceList.js b/pkg/service/soundtouchweb/static/js/components/DeviceList.js index 4785300f..3cc7c40e 100644 --- a/pkg/service/soundtouchweb/static/js/components/DeviceList.js +++ b/pkg/service/soundtouchweb/static/js/components/DeviceList.js @@ -23,6 +23,7 @@ function sortEntries(entries, mode) { function DeviceCard({ id, device, onSelect, onRemove }) { const { info, status } = device; + const stereoPair = device.stereoPair; const np = status?.nowPlaying; const isPlaying = np?.PlayStatus === 'PLAY_STATE'; const isStandby = !np || np.Source === 'STANDBY'; @@ -33,14 +34,19 @@ function DeviceCard({ id, device, onSelect, onRemove }) { ${info?.name || id} - + onClick=${(e) => { e.stopPropagation(); onRemove(id); }}>✕` : null}