mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-09-07 15:07:17 +00:00
fix(player): project stereo pairs as logical devices
This commit is contained in:
committed by
Tobias Gesellchen
parent
79eb5dd038
commit
b01ab1e1bb
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }) {
|
||||
<span class="device-name">${info?.name || id}</span>
|
||||
<span class="device-header-right">
|
||||
<span class="device-indicator ${status?.isConnected ? 'online' : 'offline'}"></span>
|
||||
<button class="device-remove" title="Remove this device"
|
||||
${!stereoPair ? html`<button class="device-remove" title="Remove this device"
|
||||
aria-label="Remove this device"
|
||||
onClick=${(e) => { e.stopPropagation(); onRemove(id); }}>✕</button>
|
||||
onClick=${(e) => { e.stopPropagation(); onRemove(id); }}>✕</button>` : null}
|
||||
</span>
|
||||
</div>
|
||||
<div class="device-type">
|
||||
${info?.type || ''}
|
||||
${info?.ip_address ? html`<span class="device-ip">(${info.ip_address})</span>` : null}
|
||||
${stereoPair ? html`
|
||||
<span class="stereo-pair-state ${stereoPair.degraded ? 'degraded' : ''}">
|
||||
Stereo pair ${stereoPair.availableMemberCount}/${stereoPair.memberCount}
|
||||
</span>
|
||||
` : null}
|
||||
</div>
|
||||
${!isStandby ? html`
|
||||
<div class="now-playing-mini">
|
||||
|
||||
@@ -23,10 +23,12 @@ export function Library({ devices }) {
|
||||
// invalidate the current selection.
|
||||
useEffect(() => {
|
||||
const entries = Object.entries(devices);
|
||||
if (!deviceId && entries.length > 0) {
|
||||
if ((!deviceId || !devices[deviceId]) && entries.length > 0) {
|
||||
setDeviceId(entries[0][0]);
|
||||
} else if (deviceId && entries.length === 0) {
|
||||
setDeviceId(null);
|
||||
}
|
||||
}, [devices]);
|
||||
}, [devices, deviceId]);
|
||||
|
||||
// Reload registered servers whenever deviceId changes.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -46,23 +46,10 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Send initial device list
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
initialMessage := webtypes.WebSocketMessage{
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{
|
||||
Type: "devices",
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(initialMessage); err != nil {
|
||||
Data: app.deviceViewSnapshot(),
|
||||
}); err != nil {
|
||||
log.Printf("Failed to send initial data: %v", err)
|
||||
return
|
||||
}
|
||||
@@ -100,21 +87,14 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Send periodic status updates
|
||||
for _, entry := range app.DeviceSnapshot() {
|
||||
status := entry.Device.Status()
|
||||
if status.IsConnected {
|
||||
statusMessage := webtypes.WebSocketMessage{
|
||||
Type: "status_update",
|
||||
DeviceID: entry.ID,
|
||||
Data: status,
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(statusMessage); err != nil {
|
||||
log.Printf("Failed to send status update: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
// A full projected list keeps pair topology and availability current
|
||||
// without event handlers writing to this browser connection.
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{
|
||||
Type: "devices",
|
||||
Data: app.deviceViewSnapshot(),
|
||||
}); err != nil {
|
||||
log.Printf("Failed to send device update: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,6 +198,10 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
|
||||
})
|
||||
})
|
||||
|
||||
wsClient.OnGroupUpdated(func(event *models.GroupUpdatedEvent) {
|
||||
applyGroupUpdatedEvent(conn, event)
|
||||
})
|
||||
|
||||
if err := wsClient.Connect(); err != nil {
|
||||
log.Printf("Failed to connect WebSocket for device %s: %v (retrying in %s)", sanitizeLog(deviceID), err, backoff)
|
||||
|
||||
@@ -298,6 +282,8 @@ func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection)
|
||||
return
|
||||
}
|
||||
|
||||
groupGeneration := conn.BeginGroupRefresh()
|
||||
|
||||
// Phase 1: slow network fetches. Local vars only, no shared state
|
||||
// is touched yet. Errors are recorded so the merge below can tell
|
||||
// "field N stayed unchanged" apart from "field N got refreshed".
|
||||
@@ -306,6 +292,7 @@ func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection)
|
||||
presets, presetsErr := conn.Client.GetPresets()
|
||||
sources, sourcesErr := conn.Client.GetSources()
|
||||
bass, bassErr := conn.Client.GetBass()
|
||||
group, groupErr := conn.Client.GetGroup()
|
||||
|
||||
// Phase 2: fast merge. Only fields we successfully fetched
|
||||
// overwrite; everything else keeps the value other goroutines may
|
||||
@@ -338,11 +325,21 @@ func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection)
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
statusUpdated = statusUpdated || groupErr == nil
|
||||
|
||||
// Mark as connected if we successfully got at least one
|
||||
// status from this round. Mirrors prior behaviour.
|
||||
s.IsConnected = statusUpdated
|
||||
s.LastActivity = time.Now()
|
||||
})
|
||||
|
||||
if groupErr == nil {
|
||||
conn.ApplyPolledGroup(groupGeneration, group)
|
||||
}
|
||||
}
|
||||
|
||||
func applyGroupUpdatedEvent(conn *webtypes.DeviceConnection, event *models.GroupUpdatedEvent) {
|
||||
conn.ApplyGroupEvent(&event.Group, time.Now())
|
||||
}
|
||||
|
||||
// HandleDeviceWebSocket handles individual device WebSocket connections for real-time device-specific updates
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package soundtouchweb
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
)
|
||||
|
||||
func TestUpdateDeviceStatusRefreshesGroup(t *testing.T) {
|
||||
server := newStatusTestServer(t, http.StatusOK, `<group id="pair-1">
|
||||
<name>Living Room</name>
|
||||
<masterDeviceId>master-1</masterDeviceId>
|
||||
<roles>
|
||||
<groupRole><deviceId>master-1</deviceId><role>LEFT</role></groupRole>
|
||||
<groupRole><deviceId>member-1</deviceId><role>RIGHT</role></groupRole>
|
||||
</roles>
|
||||
<status>GROUP_OK</status>
|
||||
</group>`)
|
||||
defer server.Close()
|
||||
|
||||
conn := webtypes.NewDeviceConnection(client.NewClientFromHost(server.URL), nil)
|
||||
NewWebApp().UpdateDeviceStatus("device-1", conn)
|
||||
|
||||
status := conn.Status()
|
||||
if status.Group == nil {
|
||||
t.Fatal("Group was not populated by UpdateDeviceStatus")
|
||||
}
|
||||
|
||||
if status.Group.ID != "pair-1" || status.Group.MasterDeviceID != "master-1" {
|
||||
t.Errorf("Group = %+v, want refreshed stereo pair", status.Group)
|
||||
}
|
||||
|
||||
if len(status.Group.Roles.Roles) != 2 {
|
||||
t.Errorf("group roles = %d, want 2", len(status.Group.Roles.Roles))
|
||||
}
|
||||
|
||||
if !status.IsConnected {
|
||||
t.Error("successful status refresh should mark the device connected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDeviceStatusPreservesGroupOnError(t *testing.T) {
|
||||
server := newStatusTestServer(t, http.StatusInternalServerError, "group unavailable")
|
||||
defer server.Close()
|
||||
|
||||
conn := webtypes.NewDeviceConnection(client.NewClientFromHost(server.URL), nil)
|
||||
existing := &models.Group{ID: "pair-old", Name: "Existing Pair"}
|
||||
conn.SetStatus(&webtypes.DeviceStatus{Group: existing})
|
||||
|
||||
NewWebApp().UpdateDeviceStatus("device-1", conn)
|
||||
|
||||
status := conn.Status()
|
||||
if status.Group != existing {
|
||||
t.Errorf("Group = %+v, want previous group preserved on refresh error", status.Group)
|
||||
}
|
||||
|
||||
if !status.IsConnected {
|
||||
t.Error("other successful status fetches should keep the device connected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyGroupUpdatedEventReplacesGroup(t *testing.T) {
|
||||
conn := webtypes.NewDeviceConnection(nil, nil)
|
||||
previousActivity := time.Unix(1, 0)
|
||||
conn.SetStatus(&webtypes.DeviceStatus{
|
||||
Group: &models.Group{ID: "pair-old"},
|
||||
Volume: &models.Volume{ActualVolume: 25},
|
||||
IsConnected: true,
|
||||
LastActivity: previousActivity,
|
||||
})
|
||||
|
||||
event := &models.GroupUpdatedEvent{
|
||||
Group: models.Group{ID: "pair-new", Name: "Renamed Pair"},
|
||||
}
|
||||
applyGroupUpdatedEvent(conn, event)
|
||||
|
||||
status := conn.Status()
|
||||
if status.Group != &event.Group || status.Group.ID != "pair-new" {
|
||||
t.Errorf("Group = %+v, want event group", status.Group)
|
||||
}
|
||||
|
||||
if status.Volume == nil || status.Volume.ActualVolume != 25 || !status.IsConnected {
|
||||
t.Errorf("unrelated status fields were not preserved: %+v", status)
|
||||
}
|
||||
|
||||
if !status.LastActivity.After(previousActivity) {
|
||||
t.Errorf("LastActivity = %s, want after %s", status.LastActivity, previousActivity)
|
||||
}
|
||||
|
||||
teardown := &models.GroupUpdatedEvent{Group: models.Group{}}
|
||||
applyGroupUpdatedEvent(conn, teardown)
|
||||
|
||||
if conn.Status().Group != nil {
|
||||
t.Errorf("teardown event did not clear the group: %+v", conn.Status().Group)
|
||||
}
|
||||
}
|
||||
|
||||
func newStatusTestServer(t *testing.T, groupStatus int, groupBody string) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
responses := map[string]string{
|
||||
"/now_playing": `<nowPlaying source="STANDBY"><playStatus>STOP_STATE</playStatus></nowPlaying>`,
|
||||
"/volume": `<volume><targetvolume>10</targetvolume><actualvolume>10</actualvolume><muteenabled>false</muteenabled></volume>`,
|
||||
"/presets": `<presets/>`,
|
||||
"/sources": `<sources/>`,
|
||||
"/bass": `<bass><targetbass>0</targetbass><actualbass>0</actualbass></bass>`,
|
||||
}
|
||||
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("method for %s = %s, want GET", r.URL.Path, r.Method)
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
|
||||
if r.URL.Path == "/getGroup" {
|
||||
w.WriteHeader(groupStatus)
|
||||
_, _ = w.Write([]byte(groupBody))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
body, ok := responses[r.URL.Path]
|
||||
if !ok {
|
||||
t.Errorf("unexpected status endpoint %q", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
}
|
||||
@@ -3,9 +3,11 @@
|
||||
package webtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
@@ -75,6 +77,7 @@ func TestUpdateStatus_PreservesUnchangedFields(t *testing.T) {
|
||||
conn.SetStatus(&DeviceStatus{
|
||||
Volume: &models.Volume{ActualVolume: 10},
|
||||
Bass: &models.Bass{ActualBass: 3},
|
||||
Group: &models.Group{ID: "pair-1", Name: "Living Room"},
|
||||
IsConnected: true,
|
||||
})
|
||||
|
||||
@@ -92,11 +95,87 @@ func TestUpdateStatus_PreservesUnchangedFields(t *testing.T) {
|
||||
t.Errorf("Bass not preserved: %+v", got.Bass)
|
||||
}
|
||||
|
||||
if got.Group == nil || got.Group.ID != "pair-1" {
|
||||
t.Errorf("Group not preserved: %+v", got.Group)
|
||||
}
|
||||
|
||||
if !got.IsConnected {
|
||||
t.Error("IsConnected not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceStatusGroupJSON(t *testing.T) {
|
||||
status := DeviceStatus{
|
||||
Group: &models.Group{
|
||||
ID: "pair-1",
|
||||
Name: "Living Room",
|
||||
MasterDeviceID: "master-1",
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal DeviceStatus: %v", err)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
Group *models.Group `json:"group"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
t.Fatalf("Unmarshal DeviceStatus: %v", err)
|
||||
}
|
||||
|
||||
if decoded.Group == nil || decoded.Group.ID != "pair-1" || decoded.Group.MasterDeviceID != "master-1" {
|
||||
t.Fatalf("group did not round-trip in status JSON: %+v", decoded.Group)
|
||||
}
|
||||
|
||||
emptyPayload, err := json.Marshal(DeviceStatus{})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal empty DeviceStatus: %v", err)
|
||||
}
|
||||
|
||||
var emptyDecoded map[string]json.RawMessage
|
||||
if err := json.Unmarshal(emptyPayload, &emptyDecoded); err != nil {
|
||||
t.Fatalf("Unmarshal empty DeviceStatus: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := emptyDecoded["group"]; ok {
|
||||
t.Errorf("nil group should be omitted, JSON = %s", emptyPayload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupEventSupersedesInFlightPoll(t *testing.T) {
|
||||
conn := NewDeviceConnection(nil, &models.DeviceInfo{Name: "test"})
|
||||
generation := conn.BeginGroupRefresh()
|
||||
|
||||
eventGroup := &models.Group{ID: "new-pair", MasterDeviceID: "master"}
|
||||
if !conn.ApplyGroupEvent(eventGroup, time.Now()) {
|
||||
t.Fatal("new group event should change group state")
|
||||
}
|
||||
|
||||
if conn.ApplyPolledGroup(generation, &models.Group{ID: "stale-pair"}) {
|
||||
t.Fatal("stale poll must not replace a newer group event")
|
||||
}
|
||||
|
||||
if got := conn.Status().Group; got == nil || got.ID != "new-pair" {
|
||||
t.Fatalf("Group = %+v, want newer event state", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyGroupClearsCurrentClaim(t *testing.T) {
|
||||
conn := NewDeviceConnection(nil, &models.DeviceInfo{Name: "test"})
|
||||
conn.SetStatus(&DeviceStatus{Group: &models.Group{ID: "pair-1"}})
|
||||
|
||||
if !conn.ApplyGroupEvent(&models.Group{}, time.Now()) {
|
||||
t.Fatal("empty teardown event should change group state")
|
||||
}
|
||||
|
||||
if got := conn.Status().Group; got != nil {
|
||||
t.Fatalf("Group = %+v, want nil after teardown", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusSnapshotIsolation(t *testing.T) {
|
||||
// A snapshot returned by Status() must NOT change when a later
|
||||
// UpdateStatus replaces a pointer field. This proves the atomic
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package webtypes
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -28,6 +29,7 @@ type SoundTouchClient interface {
|
||||
GetPresets() (*models.Presets, error)
|
||||
GetSources() (*models.Sources, error)
|
||||
GetBass() (*models.Bass, error)
|
||||
GetGroup() (*models.Group, error)
|
||||
NewWebSocketClient(config interface{}) *client.WebSocketClient
|
||||
}
|
||||
|
||||
@@ -47,6 +49,12 @@ type DeviceConnection struct {
|
||||
|
||||
status atomic.Pointer[DeviceStatus]
|
||||
|
||||
// groupMu orders polled /getGroup responses against real-time
|
||||
// groupUpdated events. Starting a newer refresh or receiving an event
|
||||
// invalidates any older in-flight poll.
|
||||
groupMu sync.Mutex
|
||||
groupGeneration uint64
|
||||
|
||||
// done is closed by Close when the device is removed from the
|
||||
// registry, signalling its background goroutines (the status poller
|
||||
// and the WebSocket reconnect loop) to exit. closeOnce keeps Close
|
||||
@@ -62,6 +70,7 @@ type DeviceStatus struct {
|
||||
Presets *models.Presets `json:"presets,omitempty"`
|
||||
Sources *models.Sources `json:"sources,omitempty"`
|
||||
Bass *models.Bass `json:"bass,omitempty"`
|
||||
Group *models.Group `json:"group,omitempty"`
|
||||
IsConnected bool `json:"isConnected"`
|
||||
LastActivity time.Time `json:"lastActivity"`
|
||||
}
|
||||
@@ -85,10 +94,9 @@ func NewDeviceConnection(c *client.Client, info *models.DeviceInfo) *DeviceConne
|
||||
}
|
||||
|
||||
// Status returns a snapshot of the current device status. The returned
|
||||
// pointer is read-only from the caller's perspective; mutating the
|
||||
// pointed-to struct has no effect on the stored status. Use
|
||||
// UpdateStatus or SetStatus to apply changes. Never returns nil for
|
||||
// connections built via NewDeviceConnection.
|
||||
// pointer is read-only from the caller's perspective and must not be
|
||||
// mutated. Use UpdateStatus or SetStatus to apply changes. Never returns
|
||||
// nil for connections built via NewDeviceConnection.
|
||||
func (c *DeviceConnection) Status() *DeviceStatus {
|
||||
return c.status.Load()
|
||||
}
|
||||
@@ -128,7 +136,7 @@ func (c *DeviceConnection) SetStatus(s *DeviceStatus) {
|
||||
// writers cannot silently lose each other's changes.
|
||||
//
|
||||
// The copy mut receives is a shallow value copy of the previous status.
|
||||
// Nested pointer fields (NowPlaying, Volume, Presets, Sources, Bass)
|
||||
// Nested pointer fields (NowPlaying, Volume, Presets, Sources, Bass, Group)
|
||||
// share their backing struct with the previous version: callers MUST
|
||||
// REPLACE these pointers (s.Volume = &models.Volume{...}) rather than
|
||||
// mutate through them (s.Volume.ActualVolume++ would race with any
|
||||
@@ -147,6 +155,61 @@ func (c *DeviceConnection) UpdateStatus(mut func(*DeviceStatus)) {
|
||||
}
|
||||
}
|
||||
|
||||
// BeginGroupRefresh starts a new generation for an asynchronous /getGroup
|
||||
// request. Only the latest started request may later update Group.
|
||||
func (c *DeviceConnection) BeginGroupRefresh() uint64 {
|
||||
c.groupMu.Lock()
|
||||
defer c.groupMu.Unlock()
|
||||
|
||||
c.groupGeneration++
|
||||
|
||||
return c.groupGeneration
|
||||
}
|
||||
|
||||
// ApplyPolledGroup stores a /getGroup result only when no newer poll or
|
||||
// groupUpdated event superseded it. Empty groups clear the current claim.
|
||||
func (c *DeviceConnection) ApplyPolledGroup(generation uint64, group *models.Group) bool {
|
||||
c.groupMu.Lock()
|
||||
defer c.groupMu.Unlock()
|
||||
|
||||
if generation != c.groupGeneration {
|
||||
return false
|
||||
}
|
||||
|
||||
return c.replaceGroup(normalizeGroup(group), time.Time{})
|
||||
}
|
||||
|
||||
// ApplyGroupEvent stores the newest groupUpdated event and invalidates all
|
||||
// in-flight /getGroup requests. Empty teardown events clear the current claim.
|
||||
func (c *DeviceConnection) ApplyGroupEvent(group *models.Group, activity time.Time) bool {
|
||||
c.groupMu.Lock()
|
||||
defer c.groupMu.Unlock()
|
||||
|
||||
c.groupGeneration++
|
||||
|
||||
return c.replaceGroup(normalizeGroup(group), activity)
|
||||
}
|
||||
|
||||
func (c *DeviceConnection) replaceGroup(group *models.Group, activity time.Time) bool {
|
||||
changed := !reflect.DeepEqual(c.Status().Group, group)
|
||||
c.UpdateStatus(func(s *DeviceStatus) {
|
||||
s.Group = group
|
||||
if !activity.IsZero() {
|
||||
s.LastActivity = activity
|
||||
}
|
||||
})
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
func normalizeGroup(group *models.Group) *models.Group {
|
||||
if group == nil || group.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return group
|
||||
}
|
||||
|
||||
// APIResponse is a standard JSON response wrapper
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
|
||||
Reference in New Issue
Block a user