mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-09-07 15:07:17 +00:00
feat: manage stereo pair lifecycle
This commit is contained in:
committed by
Tobias Gesellchen
parent
fc585e98a0
commit
a0d1fa7a04
+162
-256
@@ -3,11 +3,10 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"net/http"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/speaker"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/stereopair"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
@@ -16,32 +15,26 @@ func getGroupStatus(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Getting group information", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
group, err := client.GetGroup()
|
||||
result, err := newGroupCoordinator(clientConfig).Inspect(clientConfig.Host)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to get group: %v", err))
|
||||
printGroupResultDetails(result)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if group.IsEmpty() {
|
||||
if result.Group == nil || result.Group.IsEmpty() {
|
||||
fmt.Println("Device is not in a stereo pair")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
printGroup(group)
|
||||
printGroup(result.Group)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createGroup forms a stereo pair by POSTing /addGroup to both speakers in
|
||||
// parallel. LEFT is the master. Addressing each speaker directly (instead of
|
||||
// only the master and letting it propagate via marge) sidesteps the
|
||||
// inter-device round-trip that surfaced as client timeouts in #252.
|
||||
// createGroup forms and verifies a stereo pair. LEFT is always the master.
|
||||
func createGroup(c *cli.Context) error {
|
||||
leftIP := c.String("left")
|
||||
rightIP := c.String("right")
|
||||
@@ -49,283 +42,117 @@ func createGroup(c *cli.Context) error {
|
||||
|
||||
if net.ParseIP(leftIP) == nil {
|
||||
PrintError(fmt.Sprintf("Invalid left IP address: %s", leftIP))
|
||||
|
||||
return fmt.Errorf("invalid left IP: %s", leftIP)
|
||||
}
|
||||
|
||||
if net.ParseIP(rightIP) == nil {
|
||||
PrintError(fmt.Sprintf("Invalid right IP address: %s", rightIP))
|
||||
|
||||
return fmt.Errorf("invalid right IP: %s", rightIP)
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Creating stereo pair: LEFT=%s RIGHT=%s", leftIP, rightIP), leftIP, speaker.HTTPPort)
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader(fmt.Sprintf("Creating stereo pair: LEFT=%s RIGHT=%s", leftIP, rightIP), leftIP, clientConfig.Port)
|
||||
|
||||
leftInfo, err := fetchDeviceInfo(c, leftIP)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to read LEFT device info: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
rightInfo, err := fetchDeviceInfo(c, rightIP)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to read RIGHT device info: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("%s + %s", leftInfo.Name, rightInfo.Name)
|
||||
}
|
||||
|
||||
req := &models.Group{
|
||||
result, err := newGroupCoordinator(clientConfig).Create(stereopair.CreateRequest{
|
||||
LeftIPAddress: leftIP,
|
||||
RightIPAddress: rightIP,
|
||||
Name: name,
|
||||
MasterDeviceID: leftInfo.DeviceID,
|
||||
Roles: models.GroupRoles{
|
||||
Roles: []models.GroupRole{
|
||||
{DeviceID: leftInfo.DeviceID, Role: "LEFT", IPAddress: leftIP},
|
||||
{DeviceID: rightInfo.DeviceID, Role: "RIGHT", IPAddress: rightIP},
|
||||
},
|
||||
},
|
||||
// SenderIPAddress is intentionally omitted on the base request.
|
||||
// propagateAddGroup adds it to the slave's copy only — see comment there.
|
||||
}
|
||||
|
||||
leftClient, err := clientForHost(c, leftIP)
|
||||
})
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client for LEFT: %v", err))
|
||||
PrintError(fmt.Sprintf("Failed to create stereo pair: %v", err))
|
||||
printGroupResultDetails(result)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
rightClient, err := clientForHost(c, rightIP)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client for RIGHT: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, leftIP, rightIP, req)
|
||||
|
||||
if leftOut.err != nil {
|
||||
PrintError(fmt.Sprintf("LEFT (%s) /addGroup failed: %v", leftIP, leftOut.err))
|
||||
}
|
||||
|
||||
if rightOut.err != nil {
|
||||
PrintError(fmt.Sprintf("RIGHT (%s) /addGroup failed: %v", rightIP, rightOut.err))
|
||||
}
|
||||
|
||||
if leftOut.err != nil || rightOut.err != nil {
|
||||
if (leftOut.err == nil) != (rightOut.err == nil) {
|
||||
succeeded := leftIP
|
||||
if leftOut.err != nil {
|
||||
succeeded = rightIP
|
||||
}
|
||||
|
||||
PrintError(fmt.Sprintf("Partial group state on %s — clean up with `soundtouch-cli --host %s group remove`", succeeded, succeeded))
|
||||
}
|
||||
|
||||
return fmt.Errorf("/addGroup propagation failed")
|
||||
}
|
||||
|
||||
// The LEFT (master) response carries the assigned group ID; use it for display.
|
||||
PrintSuccess(fmt.Sprintf("Stereo pair created (id=%s)", leftOut.group.ID))
|
||||
printGroup(leftOut.group)
|
||||
PrintSuccess(fmt.Sprintf("Stereo pair created (id=%s)", result.Group.ID))
|
||||
printGroup(result.Group)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addGroupOutcome is the per-speaker result of a parallel /addGroup call.
|
||||
type addGroupOutcome struct {
|
||||
host string
|
||||
group *models.Group
|
||||
err error
|
||||
}
|
||||
|
||||
// propagateAddGroup POSTs /addGroup to both speakers concurrently and returns
|
||||
// the (LEFT, RIGHT) outcomes. A non-GROUP_OK Status in the response is
|
||||
// reported as an error so callers don't have to re-inspect the body.
|
||||
//
|
||||
// The two POSTs carry different payloads: the master (LEFT) receives the base
|
||||
// request with no senderIPAddress so its state machine forms the group as the
|
||||
// master, while the slave (RIGHT) receives a copy with senderIPAddress set to
|
||||
// the master's IP so its state machine joins as the slave. Sending the same
|
||||
// payload to both makes both speakers think they're the slave — they enter
|
||||
// AddingSlave, wait for a master that never confirms, time out after 5 s, and
|
||||
// revert (issue #252).
|
||||
func propagateAddGroup(left, right *client.Client, leftIP, rightIP string, req *models.Group) (addGroupOutcome, addGroupOutcome) {
|
||||
masterReq := *req
|
||||
masterReq.SenderIPAddress = ""
|
||||
|
||||
slaveReq := *req
|
||||
slaveReq.SenderIPAddress = leftIP
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
leftOut, rightOut addGroupOutcome
|
||||
)
|
||||
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
leftOut = postAddGroup(left, leftIP, &masterReq)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
rightOut = postAddGroup(right, rightIP, &slaveReq)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return leftOut, rightOut
|
||||
}
|
||||
|
||||
func postAddGroup(cli *client.Client, host string, req *models.Group) addGroupOutcome {
|
||||
out := addGroupOutcome{host: host}
|
||||
|
||||
g, err := cli.AddGroup(req)
|
||||
if err != nil {
|
||||
out.err = err
|
||||
return out
|
||||
}
|
||||
|
||||
out.group = g
|
||||
|
||||
if g != nil && g.Status != "" && g.Status != "GROUP_OK" {
|
||||
out.err = fmt.Errorf("device returned status %q (want GROUP_OK)", g.Status)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// renameGroup updates the name of the existing stereo pair. The device
|
||||
// requires the full structure on every update, so we fetch the current
|
||||
// state first.
|
||||
// renameGroup updates and verifies the name on both stereo-pair members.
|
||||
func renameGroup(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
newName := c.String("name")
|
||||
|
||||
if newName == "" {
|
||||
PrintError("--name is required")
|
||||
|
||||
return fmt.Errorf("name is required")
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Renaming stereo pair to %q", newName), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
stClient, err := CreateSoundTouchClient(clientConfig)
|
||||
coordinator := newGroupCoordinator(clientConfig)
|
||||
|
||||
current, err := coordinator.Inspect(clientConfig.Host)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
PrintError(fmt.Sprintf("Failed to inspect stereo pair before rename: %v", err))
|
||||
printGroupResultDetails(current)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
current, err := stClient.GetGroup()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to read current group: %v", err))
|
||||
return err
|
||||
if current.Group == nil || current.Group.IsEmpty() {
|
||||
return fmt.Errorf("device is not in a stereo pair")
|
||||
}
|
||||
|
||||
if current.IsEmpty() {
|
||||
PrintError("Device is not in a stereo pair — nothing to rename")
|
||||
return fmt.Errorf("no group configured")
|
||||
}
|
||||
|
||||
// Status is read-only on the device side; don't echo it back.
|
||||
current.Status = ""
|
||||
current.Name = newName
|
||||
|
||||
result, err := stClient.UpdateGroup(current)
|
||||
result, err := coordinator.Rename(stereopair.RenameRequest{
|
||||
MemberIPAddress: clientConfig.Host,
|
||||
ExpectedGroupID: current.Group.ID,
|
||||
Name: newName,
|
||||
})
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to rename group: %v", err))
|
||||
printGroupResultDetails(result)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Stereo pair renamed to %q", result.Name))
|
||||
printGroup(result)
|
||||
PrintSuccess(fmt.Sprintf("Stereo pair renamed to %q", result.Group.Name))
|
||||
printGroup(result.Group)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeGroup tears down the device's stereo pair by sending /removeGroup to
|
||||
// every member in parallel. Sending it only to the master (as the old code
|
||||
// did) leaves the slave stuck in GroupSlave state indefinitely — mirrors the
|
||||
// same symmetry as createGroup (see issue #252 comment there).
|
||||
// removeGroup dissolves and verifies the stereo pair on every member.
|
||||
func removeGroup(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Removing stereo pair", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
stClient, err := CreateSoundTouchClient(clientConfig)
|
||||
coordinator := newGroupCoordinator(clientConfig)
|
||||
|
||||
current, err := coordinator.Inspect(clientConfig.Host)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
PrintWarning(fmt.Sprintf("Stereo pair is degraded before removal: %v", err))
|
||||
printGroupResultDetails(current)
|
||||
|
||||
if current.Group == nil || current.Group.IsEmpty() {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch current group to learn every member's IP before tearing down.
|
||||
group, err := stClient.GetGroup()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to read current group: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if group.IsEmpty() {
|
||||
if current.Group == nil || current.Group.IsEmpty() {
|
||||
fmt.Println("Device is not in a stereo pair — nothing to remove")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect the unique set of member IPs. The master is always reachable
|
||||
// via clientConfig.Host; the roles carry all members including slaves.
|
||||
type memberResult struct {
|
||||
ip string
|
||||
err error
|
||||
}
|
||||
dissolveHost := dissolveRecoveryHost(current, clientConfig.Host)
|
||||
|
||||
members := make([]string, 0, len(group.Roles.Roles))
|
||||
seen := map[string]bool{}
|
||||
result, err := coordinator.Dissolve(stereopair.DissolveRequest{
|
||||
MemberIPAddress: dissolveHost,
|
||||
ExpectedGroupID: current.Group.ID,
|
||||
ExpectedGroup: current.Group,
|
||||
})
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to remove stereo pair: %v", err))
|
||||
printGroupResultDetails(result)
|
||||
|
||||
for _, role := range group.Roles.Roles {
|
||||
if role.IPAddress != "" && !seen[role.IPAddress] {
|
||||
seen[role.IPAddress] = true
|
||||
members = append(members, role.IPAddress)
|
||||
}
|
||||
}
|
||||
|
||||
// Always include the addressed host even if the group response omitted IPs.
|
||||
if !seen[clientConfig.Host] {
|
||||
members = append(members, clientConfig.Host)
|
||||
}
|
||||
|
||||
results := make([]memberResult, len(members))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, ip := range members {
|
||||
wg.Add(1)
|
||||
|
||||
go func(idx int, host string) {
|
||||
defer wg.Done()
|
||||
|
||||
mc, mcErr := clientForHost(c, host)
|
||||
if mcErr != nil {
|
||||
results[idx] = memberResult{ip: host, err: mcErr}
|
||||
return
|
||||
}
|
||||
|
||||
results[idx] = memberResult{ip: host, err: mc.RemoveGroup()}
|
||||
}(i, ip)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
anyErr := false
|
||||
|
||||
for _, r := range results {
|
||||
if r.err != nil {
|
||||
PrintError(fmt.Sprintf("%s /removeGroup failed: %v", r.ip, r.err))
|
||||
|
||||
anyErr = true
|
||||
}
|
||||
}
|
||||
|
||||
if anyErr {
|
||||
return fmt.Errorf("/removeGroup propagation failed")
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess("Stereo pair removed")
|
||||
@@ -333,32 +160,111 @@ func removeGroup(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchDeviceInfo builds a one-off client for the given IP and reads /info.
|
||||
// Reused for both halves of a `create` invocation so the caller doesn't have
|
||||
// to babysit two host/port pairs.
|
||||
func fetchDeviceInfo(c *cli.Context, host string) (*models.DeviceInfo, error) {
|
||||
stClient, err := clientForHost(c, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func dissolveRecoveryHost(result stereopair.Result, fallback string) string {
|
||||
if result.Group == nil || result.Group.ID == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return stClient.GetDeviceInfo()
|
||||
for i := range result.Members {
|
||||
member := &result.Members[i]
|
||||
if member.Group != nil && member.Group.ID == result.Group.ID && net.ParseIP(member.IPAddress) != nil {
|
||||
return member.IPAddress
|
||||
}
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
// clientForHost mirrors CreateSoundTouchClient but overrides the host so we
|
||||
// can talk to a speaker other than the one named in --host.
|
||||
func clientForHost(c *cli.Context, host string) (*client.Client, error) {
|
||||
cfg, err := loadConfig(c.Duration("timeout"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||
func newGroupCoordinator(config *ClientConfig) *stereopair.Coordinator {
|
||||
lifecycleConfig := *config
|
||||
if lifecycleConfig.Timeout < stereopair.RequestTimeout {
|
||||
lifecycleConfig.Timeout = stereopair.RequestTimeout
|
||||
}
|
||||
|
||||
return client.NewClient(&client.Config{
|
||||
Host: host,
|
||||
Port: speaker.HTTPPort,
|
||||
Timeout: cfg.HTTPTimeout,
|
||||
UserAgent: cfg.UserAgent,
|
||||
}), nil
|
||||
cleanupClient := &http.Client{Timeout: lifecycleConfig.Timeout}
|
||||
|
||||
return stereopair.NewWithGenerationLifecyclePersistence(
|
||||
groupClientFactory(&lifecycleConfig),
|
||||
func(ref stereopair.GenerationRef) error {
|
||||
return stereopair.DeleteMargeGroupGeneration(cleanupClient, ref)
|
||||
},
|
||||
func(refs []stereopair.GenerationRef) error {
|
||||
return stereopair.EnsureMargeNoGroupGenerations(cleanupClient, refs)
|
||||
},
|
||||
func(ref stereopair.GenerationRef, name string) error {
|
||||
return stereopair.RenameMargeGroupGeneration(cleanupClient, ref, name)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// groupClientFactory addresses every member directly while retaining the
|
||||
// effective CLI port and timeout.
|
||||
func groupClientFactory(config *ClientConfig) stereopair.ClientFactory {
|
||||
baseConfig := *config
|
||||
|
||||
return func(ipAddress string) (stereopair.Client, error) {
|
||||
memberConfig := baseConfig
|
||||
memberConfig.Host = ipAddress
|
||||
|
||||
return CreateSoundTouchClient(&memberConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func printGroupResultDetails(result stereopair.Result) {
|
||||
if result.Status == stereopair.StatusDegraded {
|
||||
PrintWarning(fmt.Sprintf("Stereo-pair %s result is degraded", result.Operation))
|
||||
}
|
||||
|
||||
for i := range result.Members {
|
||||
member := &result.Members[i]
|
||||
label := groupMemberLabel(i, member)
|
||||
|
||||
if member.PreflightError != nil {
|
||||
PrintError(fmt.Sprintf("%s preflight failed: %v", label, member.PreflightError))
|
||||
}
|
||||
|
||||
if member.MutationError != nil {
|
||||
PrintError(fmt.Sprintf("%s mutation failed: %v", label, member.MutationError))
|
||||
}
|
||||
|
||||
if member.VerificationError != nil {
|
||||
PrintError(fmt.Sprintf("%s verification failed: %v", label, member.VerificationError))
|
||||
}
|
||||
|
||||
if member.CompensationError != nil {
|
||||
PrintError(fmt.Sprintf("%s cleanup failed: %v", label, member.CompensationError))
|
||||
} else if member.CompensationAttempted && !member.CompensationVerified {
|
||||
PrintWarning(fmt.Sprintf("%s cleanup could not be verified", label))
|
||||
}
|
||||
}
|
||||
|
||||
if result.CompensationAttempted {
|
||||
if result.CompensationComplete {
|
||||
PrintWarning("Partial stereo-pair state was cleaned up and verified")
|
||||
} else {
|
||||
PrintError("Partial stereo-pair state cleanup is incomplete")
|
||||
}
|
||||
}
|
||||
|
||||
if result.PersistenceError != nil {
|
||||
PrintError(fmt.Sprintf("Persistent group generation update failed: %v", result.PersistenceError))
|
||||
}
|
||||
}
|
||||
|
||||
func groupMemberLabel(index int, member *stereopair.MemberResult) string {
|
||||
if member.IPAddress != "" && member.DeviceID != "" {
|
||||
return fmt.Sprintf("%s (%s)", member.IPAddress, member.DeviceID)
|
||||
}
|
||||
|
||||
if member.IPAddress != "" {
|
||||
return member.IPAddress
|
||||
}
|
||||
|
||||
if member.DeviceID != "" {
|
||||
return member.DeviceID
|
||||
}
|
||||
|
||||
return fmt.Sprintf("member %d", index+1)
|
||||
}
|
||||
|
||||
func printGroup(g *models.Group) {
|
||||
|
||||
@@ -1,184 +1,211 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/stereopair"
|
||||
)
|
||||
|
||||
// happyAddGroupServer fakes a speaker's /addGroup that echoes the request
|
||||
// with an assigned ID and GROUP_OK status, matching real hardware behaviour.
|
||||
func happyAddGroupServer(t *testing.T, assignedID string) (*httptest.Server, *[]string) {
|
||||
t.Helper()
|
||||
|
||||
bodies := make([]string, 0)
|
||||
|
||||
func TestGroupClientFactoryUsesMemberHostAndConfiguredPort(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/addGroup" || r.Method != http.MethodPost {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
if r.URL.Path != "/getGroup" {
|
||||
t.Errorf("path = %q, want /getGroup", r.URL.Path)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
bodies = append(bodies, string(body))
|
||||
|
||||
var got models.Group
|
||||
if err := xml.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
|
||||
got.ID = assignedID
|
||||
got.Status = "GROUP_OK"
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
|
||||
enc, _ := xml.Marshal(&got)
|
||||
_, _ = w.Write(enc)
|
||||
_, _ = w.Write([]byte(`<group id="pair-id"><name>Pair</name></group>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
return srv, &bodies
|
||||
}
|
||||
host, port := testServerHostPort(t, srv.URL)
|
||||
factory := groupClientFactory(&ClientConfig{
|
||||
Host: "192.0.2.200",
|
||||
Port: port,
|
||||
Timeout: time.Second,
|
||||
})
|
||||
|
||||
func newTestGroupClient(serverURL string) *client.Client {
|
||||
return client.NewClientFromHost(serverURL)
|
||||
}
|
||||
memberClient, err := factory(host)
|
||||
if err != nil {
|
||||
t.Fatalf("factory: %v", err)
|
||||
}
|
||||
|
||||
func sampleGroupRequest(leftIP, rightIP string) *models.Group {
|
||||
return &models.Group{
|
||||
Name: "Living Room",
|
||||
MasterDeviceID: "9070658C9D4A",
|
||||
Roles: models.GroupRoles{
|
||||
Roles: []models.GroupRole{
|
||||
{DeviceID: "9070658C9D4A", Role: "LEFT", IPAddress: leftIP},
|
||||
{DeviceID: "F45EAB3115DA", Role: "RIGHT", IPAddress: rightIP},
|
||||
},
|
||||
},
|
||||
// senderIPAddress is intentionally not set here; propagateAddGroup
|
||||
// adds it to the slave's copy only.
|
||||
group, err := memberClient.GetGroup()
|
||||
if err != nil {
|
||||
t.Fatalf("GetGroup: %v", err)
|
||||
}
|
||||
|
||||
if group.ID != "pair-id" || group.Name != "Pair" {
|
||||
t.Fatalf("group = %+v, want test server response", group)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPropagateAddGroup_BothSucceed(t *testing.T) {
|
||||
leftSrv, leftBodies := happyAddGroupServer(t, "9999999")
|
||||
defer leftSrv.Close()
|
||||
func TestGroupClientFactoryUsesConfiguredTimeout(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
_, _ = w.Write([]byte(`<group/>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
rightSrv, rightBodies := happyAddGroupServer(t, "9999999")
|
||||
defer rightSrv.Close()
|
||||
|
||||
leftClient := newTestGroupClient(leftSrv.URL)
|
||||
rightClient := newTestGroupClient(rightSrv.URL)
|
||||
|
||||
req := sampleGroupRequest("192.0.2.131", "192.0.2.134")
|
||||
|
||||
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, "192.0.2.131", "192.0.2.134", req)
|
||||
|
||||
if leftOut.err != nil {
|
||||
t.Errorf("LEFT err = %v, want nil", leftOut.err)
|
||||
host, port := testServerHostPort(t, srv.URL)
|
||||
factory := groupClientFactory(&ClientConfig{Port: port, Timeout: 5 * time.Millisecond})
|
||||
memberClient, err := factory(host)
|
||||
if err != nil {
|
||||
t.Fatalf("factory: %v", err)
|
||||
}
|
||||
|
||||
if rightOut.err != nil {
|
||||
t.Errorf("RIGHT err = %v, want nil", rightOut.err)
|
||||
if _, err := memberClient.GetGroup(); err == nil {
|
||||
t.Fatal("GetGroup succeeded, want configured timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeGroupGenerationURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
base string
|
||||
want string
|
||||
}{
|
||||
{base: "http://aftertouch.example:8000", want: "http://aftertouch.example:8000/streaming/account/ACCOUNT1/group/PAIR1"},
|
||||
{base: "http://unifi:8001/marge", want: "http://unifi:8001/marge/streaming/account/ACCOUNT1/group/PAIR1"},
|
||||
{base: "https://proxy.example/prefix/streaming/", want: "https://proxy.example/prefix/streaming/account/ACCOUNT1/group/PAIR1"},
|
||||
}
|
||||
|
||||
if leftOut.group == nil || leftOut.group.ID != "9999999" || leftOut.group.Status != "GROUP_OK" {
|
||||
t.Errorf("LEFT group = %+v, want id=9999999 status=GROUP_OK", leftOut.group)
|
||||
}
|
||||
|
||||
if rightOut.group == nil || rightOut.group.Status != "GROUP_OK" {
|
||||
t.Errorf("RIGHT group = %+v, want status=GROUP_OK", rightOut.group)
|
||||
}
|
||||
|
||||
// Both speakers must have received the roles, but only the slave's payload
|
||||
// carries senderIPAddress — see propagateAddGroup for the why.
|
||||
for label, bodies := range map[string]*[]string{"LEFT": leftBodies, "RIGHT": rightBodies} {
|
||||
if len(*bodies) != 1 {
|
||||
t.Fatalf("%s: expected exactly one POST, got %d", label, len(*bodies))
|
||||
for _, test := range tests {
|
||||
got, err := stereopair.MargeGroupGenerationURL(stereopair.GenerationRef{
|
||||
MargeURL: test.base, AccountID: "ACCOUNT1", GroupID: "PAIR1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("margeGroupGenerationURL(%q): %v", test.base, err)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Errorf("margeGroupGenerationURL(%q) = %q, want %q", test.base, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body := (*bodies)[0]
|
||||
for _, want := range []string{"<role>LEFT</role>", "<role>RIGHT</role>"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("%s body missing %q\nbody:\n%s", label, want, body)
|
||||
func TestDeleteMargeGroupGenerationUsesExactEndpoint(t *testing.T) {
|
||||
deleteSeen := false
|
||||
getSeen := false
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodDelete:
|
||||
deleteSeen = true
|
||||
if r.URL.Path != "/streaming/account/ACCOUNT1/group/PAIR1" {
|
||||
t.Errorf("DELETE path = %s", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case http.MethodGet:
|
||||
getSeen = true
|
||||
if r.URL.Path != "/streaming/account/ACCOUNT1/device/LEFT-ID/group" {
|
||||
t.Errorf("GET path = %s", r.URL.Path)
|
||||
}
|
||||
if deleteSeen {
|
||||
_, _ = w.Write([]byte(`<group/>`))
|
||||
} else {
|
||||
_, _ = w.Write([]byte(`<group id="PAIR1"><masterDeviceId>LEFT-ID</masterDeviceId><roles><groupRole><deviceId>LEFT-ID</deviceId><role>LEFT</role><ipAddress>192.0.2.10</ipAddress></groupRole><groupRole><deviceId>RIGHT-ID</deviceId><role>RIGHT</role><ipAddress>192.0.2.11</ipAddress></groupRole></roles></group>`))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
leftBody := (*leftBodies)[0]
|
||||
if strings.Contains(leftBody, "<senderIPAddress>") {
|
||||
t.Errorf("LEFT (master) body must NOT carry <senderIPAddress>, otherwise the master flips into slave mode (issue #252)\nbody:\n%s", leftBody)
|
||||
}
|
||||
|
||||
rightBody := (*rightBodies)[0]
|
||||
if !strings.Contains(rightBody, "<senderIPAddress>192.0.2.131</senderIPAddress>") {
|
||||
t.Errorf("RIGHT (slave) body must carry <senderIPAddress>192.0.2.131</senderIPAddress>\nbody:\n%s", rightBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPropagateAddGroup_RightFails(t *testing.T) {
|
||||
leftSrv, _ := happyAddGroupServer(t, "9999999")
|
||||
defer leftSrv.Close()
|
||||
|
||||
rightSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
}))
|
||||
defer rightSrv.Close()
|
||||
defer server.Close()
|
||||
|
||||
leftClient := newTestGroupClient(leftSrv.URL)
|
||||
rightClient := newTestGroupClient(rightSrv.URL)
|
||||
|
||||
req := sampleGroupRequest("192.0.2.131", "192.0.2.134")
|
||||
|
||||
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, "192.0.2.131", "192.0.2.134", req)
|
||||
|
||||
if leftOut.err != nil {
|
||||
t.Errorf("LEFT err = %v, want nil", leftOut.err)
|
||||
err := stereopair.DeleteMargeGroupGeneration(server.Client(), stereopair.GenerationRef{
|
||||
MargeURL: server.URL, AccountID: "ACCOUNT1", GroupID: "PAIR1", DeviceID: "LEFT-ID",
|
||||
ExpectedGroup: &models.Group{
|
||||
ID: "PAIR1",
|
||||
MasterDeviceID: "LEFT-ID",
|
||||
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"},
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("deleteMargeGroupGeneration: %v", err)
|
||||
}
|
||||
|
||||
if rightOut.err == nil {
|
||||
t.Error("RIGHT err = nil, want non-nil")
|
||||
if !deleteSeen || !getSeen {
|
||||
t.Fatalf("Marge cleanup requests DELETE=%t GET=%t, want both", deleteSeen, getSeen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostAddGroup_StatusOtherThanGroupOKIsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<group><status>GROUP_NOT_READY</status></group>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out := postAddGroup(newTestGroupClient(srv.URL), "test", sampleGroupRequest("1.1.1.1", "2.2.2.2"))
|
||||
|
||||
if out.err == nil {
|
||||
t.Fatal("expected error for non-GROUP_OK status")
|
||||
func TestPrintGroupResultDetailsReportsMemberFailuresAndCleanup(t *testing.T) {
|
||||
result := stereopair.Result{
|
||||
Operation: stereopair.OperationCreate,
|
||||
Status: stereopair.StatusDegraded,
|
||||
CompensationAttempted: true,
|
||||
PersistenceError: errors.New("datastore unavailable"),
|
||||
Members: []stereopair.MemberResult{
|
||||
{
|
||||
IPAddress: "192.0.2.10",
|
||||
DeviceID: "LEFT-ID",
|
||||
PreflightError: errors.New("offline"),
|
||||
},
|
||||
{
|
||||
IPAddress: "192.0.2.11",
|
||||
MutationError: errors.New("add failed"),
|
||||
VerificationError: errors.New("unexpected group"),
|
||||
CompensationAttempted: true,
|
||||
CompensationError: errors.New("remove failed"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if !strings.Contains(out.err.Error(), "GROUP_NOT_READY") {
|
||||
t.Errorf("error %q does not mention returned status", out.err)
|
||||
output := captureStdout(t, func() {
|
||||
printGroupResultDetails(result)
|
||||
})
|
||||
|
||||
for _, expected := range []string{
|
||||
"Stereo-pair create result is degraded",
|
||||
"192.0.2.10 (LEFT-ID) preflight failed: offline",
|
||||
"192.0.2.11 mutation failed: add failed",
|
||||
"192.0.2.11 verification failed: unexpected group",
|
||||
"192.0.2.11 cleanup failed: remove failed",
|
||||
"Partial stereo-pair state cleanup is incomplete",
|
||||
"Persistent group generation update failed: datastore unavailable",
|
||||
} {
|
||||
if !strings.Contains(output, expected) {
|
||||
t.Errorf("output missing %q:\n%s", expected, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostAddGroup_EmptyStatusIsAccepted(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<group id="42"><name>n</name></group>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out := postAddGroup(newTestGroupClient(srv.URL), "test", sampleGroupRequest("1.1.1.1", "2.2.2.2"))
|
||||
|
||||
if out.err != nil {
|
||||
t.Errorf("err = %v, want nil for empty status (some firmware omits it)", out.err)
|
||||
func TestDissolveRecoveryHostSelectsStillGroupedMember(t *testing.T) {
|
||||
result := stereopair.Result{
|
||||
Group: &models.Group{ID: "PAIR-ID"},
|
||||
Members: []stereopair.MemberResult{
|
||||
{IPAddress: "192.0.2.10", Group: &models.Group{}},
|
||||
{IPAddress: "192.0.2.11", Group: &models.Group{ID: "PAIR-ID"}},
|
||||
},
|
||||
}
|
||||
|
||||
if out.group == nil || out.group.ID != "42" {
|
||||
t.Errorf("group = %+v, want id=42", out.group)
|
||||
if got := dissolveRecoveryHost(result, "192.0.2.10"); got != "192.0.2.11" {
|
||||
t.Fatalf("recovery host = %q, want surviving member", got)
|
||||
}
|
||||
}
|
||||
|
||||
func testServerHostPort(t *testing.T, serverURL string) (string, int) {
|
||||
t.Helper()
|
||||
|
||||
parsed, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse server URL: %v", err)
|
||||
}
|
||||
|
||||
host, portText, err := net.SplitHostPort(parsed.Host)
|
||||
if err != nil {
|
||||
t.Fatalf("split server host: %v", err)
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
t.Fatalf("parse server port: %v", err)
|
||||
}
|
||||
|
||||
return host, port
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ Based on captured WebSocket interactions and device API capabilities, this web U
|
||||
- **Real-time status monitoring** via WebSocket connections
|
||||
- **Multi-device support** with centralized control
|
||||
- **Connection status** indicators and health monitoring
|
||||
- **SoundTouch 10 stereo pairs** shown as one target, with verified create,
|
||||
rename, and dissolve operations across both physical speakers and their
|
||||
exact persisted group generation
|
||||
|
||||
### Playback Control
|
||||
- **Play/Pause/Stop/Next/Previous** controls
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -34,6 +35,7 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/stockholm"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/stereopair"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/urfave/cli/v2"
|
||||
@@ -1504,6 +1506,19 @@ func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, d
|
||||
return err
|
||||
}
|
||||
|
||||
// Preserve the datastore's atomic, all-account guarantees for speakers that
|
||||
// point at this service, while following fresh /info to an external Marge
|
||||
// backend for speakers still managed by SoundCork or another service.
|
||||
cleanup, preflight, rename := embeddedStereoPairGenerationPersistence(
|
||||
ds,
|
||||
func() []string {
|
||||
localServerURL, localHTTPSServerURL := server.GetSettings()
|
||||
return []string{localServerURL, localHTTPSServerURL}
|
||||
},
|
||||
&http.Client{Timeout: stereopair.RequestTimeout},
|
||||
)
|
||||
webApp.SetStereoPairGenerationPersistence(cleanup, preflight, rename)
|
||||
|
||||
// Keep the UI registry live as the service discovers or devices are added.
|
||||
server.SetDevicesChangedHook(func() {
|
||||
webApp.SeedExtraDevices()
|
||||
@@ -1531,6 +1546,71 @@ func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, d
|
||||
return webApp
|
||||
}
|
||||
|
||||
func embeddedStereoPairGenerationPersistence(
|
||||
ds *datastore.DataStore,
|
||||
localMargeURLs func() []string,
|
||||
httpClient *http.Client,
|
||||
) (stereopair.GenerationCleanup, stereopair.GenerationPreflight, stereopair.GenerationRename) {
|
||||
isLocal := func(margeURL string, localURLs []string) bool {
|
||||
for _, localURL := range localURLs {
|
||||
if stereopair.SameMargeBackend(margeURL, localURL) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
cleanup := func(ref stereopair.GenerationRef) error {
|
||||
if isLocal(ref.MargeURL, localMargeURLs()) {
|
||||
return ds.DeleteGroupGenerationForDevice(ref.DeviceID, ref.GroupID, ref.ExpectedGroup)
|
||||
}
|
||||
|
||||
return stereopair.DeleteMargeGroupGeneration(httpClient, ref)
|
||||
}
|
||||
|
||||
preflight := func(refs []stereopair.GenerationRef) error {
|
||||
localURLs := localMargeURLs()
|
||||
localDeviceIDs := make([]string, 0, len(refs))
|
||||
externalRefs := make([]stereopair.GenerationRef, 0, len(refs))
|
||||
|
||||
for i := range refs {
|
||||
if isLocal(refs[i].MargeURL, localURLs) {
|
||||
localDeviceIDs = append(localDeviceIDs, refs[i].DeviceID)
|
||||
} else {
|
||||
externalRefs = append(externalRefs, refs[i])
|
||||
}
|
||||
}
|
||||
|
||||
if len(localDeviceIDs) > 0 {
|
||||
if err := ds.EnsureNoGroupsForDevices(localDeviceIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(externalRefs) > 0 {
|
||||
return stereopair.EnsureMargeNoGroupGenerations(httpClient, externalRefs)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
rename := func(ref stereopair.GenerationRef, name string) error {
|
||||
if isLocal(ref.MargeURL, localMargeURLs()) {
|
||||
_, err := ds.RenameGroupGenerationForDevice(ref.DeviceID, ref.GroupID, ref.ExpectedGroup, name)
|
||||
if errors.Is(err, datastore.ErrGroupNotFound) || errors.Is(err, datastore.ErrGroupDeleteAmbiguous) {
|
||||
return fmt.Errorf("%w: %w", stereopair.ErrConflict, err)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return stereopair.RenameMargeGroupGeneration(httpClient, ref, name)
|
||||
}
|
||||
|
||||
return cleanup, preflight, rename
|
||||
}
|
||||
|
||||
func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, webApp *soundtouchweb.WebApp) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/stereopair"
|
||||
)
|
||||
|
||||
type rejectingRoundTripper struct{}
|
||||
|
||||
func (rejectingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("unexpected HTTP persistence request")
|
||||
}
|
||||
|
||||
func persistenceTestGroup(id string) *models.Group {
|
||||
return &models.Group{
|
||||
ID: id,
|
||||
Name: "Living room",
|
||||
MasterDeviceID: "LEFT-ID",
|
||||
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 TestEmbeddedStereoPairPersistenceUsesLocalDatastoreAcrossAccounts(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
group := persistenceTestGroup("")
|
||||
groupID, err := ds.AddGroup("OLD-ACCOUNT", group)
|
||||
if err != nil {
|
||||
t.Fatalf("AddGroup: %v", err)
|
||||
}
|
||||
|
||||
localURL := "https://aftertouch.invalid:18443"
|
||||
cleanup, preflight, rename := embeddedStereoPairGenerationPersistence(
|
||||
ds,
|
||||
func() []string { return []string{localURL} },
|
||||
&http.Client{Transport: rejectingRoundTripper{}},
|
||||
)
|
||||
|
||||
err = preflight([]stereopair.GenerationRef{{
|
||||
DeviceID: "LEFT-ID", AccountID: "NEW-ACCOUNT", MargeURL: localURL,
|
||||
}})
|
||||
if err == nil || !strings.Contains(err.Error(), groupID) {
|
||||
t.Fatalf("preflight error = %v, want cross-account generation %s", err, groupID)
|
||||
}
|
||||
if err := rename(stereopair.GenerationRef{
|
||||
DeviceID: "LEFT-ID", AccountID: "NEW-ACCOUNT", MargeURL: localURL,
|
||||
GroupID: groupID, ExpectedGroup: group,
|
||||
}, "Renamed living room"); err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
group.Name = "Renamed living room"
|
||||
|
||||
if err := cleanup(stereopair.GenerationRef{
|
||||
DeviceID: "LEFT-ID", AccountID: "NEW-ACCOUNT", MargeURL: localURL,
|
||||
GroupID: groupID, ExpectedGroup: group,
|
||||
}); err != nil {
|
||||
t.Fatalf("cleanup: %v", err)
|
||||
}
|
||||
|
||||
if err := preflight([]stereopair.GenerationRef{{
|
||||
DeviceID: "LEFT-ID", AccountID: "NEW-ACCOUNT", MargeURL: localURL,
|
||||
}}); err != nil {
|
||||
t.Fatalf("preflight after exact cleanup: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedStereoPairPersistenceUsesExternalMargeBackend(t *testing.T) {
|
||||
active := true
|
||||
deleteCalls := 0
|
||||
postCalls := 0
|
||||
expected := persistenceTestGroup("7654321")
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/device/LEFT-ID/group"):
|
||||
if !active {
|
||||
_, _ = w.Write([]byte(`<group/>`))
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `<group id="%s"><name>%s</name><masterDeviceId>%s</masterDeviceId><roles><groupRole><deviceId>LEFT-ID</deviceId><role>LEFT</role><ipAddress>192.0.2.10</ipAddress></groupRole><groupRole><deviceId>RIGHT-ID</deviceId><role>RIGHT</role><ipAddress>192.0.2.11</ipAddress></groupRole></roles></group>`, expected.ID, expected.Name, expected.MasterDeviceID)
|
||||
case r.Method == http.MethodDelete && strings.HasSuffix(r.URL.Path, "/group/"+expected.ID):
|
||||
deleteCalls++
|
||||
active = false
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/group/"+expected.ID):
|
||||
postCalls++
|
||||
var update models.Group
|
||||
if err := xml.NewDecoder(r.Body).Decode(&update); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
expected = &update
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cleanup, _, rename := embeddedStereoPairGenerationPersistence(
|
||||
datastore.NewDataStore(t.TempDir()),
|
||||
func() []string { return []string{"http://aftertouch.invalid:18000"} },
|
||||
server.Client(),
|
||||
)
|
||||
if err := rename(stereopair.GenerationRef{
|
||||
DeviceID: "LEFT-ID", AccountID: "ACCOUNT1", MargeURL: server.URL + "/marge",
|
||||
GroupID: expected.ID, ExpectedGroup: persistenceTestGroup(expected.ID),
|
||||
}, "Renamed living room"); err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
if postCalls != 1 || expected.Name != "Renamed living room" {
|
||||
t.Fatalf("external POST calls = %d, name = %q; want 1, Renamed living room", postCalls, expected.Name)
|
||||
}
|
||||
|
||||
if err := cleanup(stereopair.GenerationRef{
|
||||
DeviceID: "LEFT-ID", AccountID: "ACCOUNT1", MargeURL: server.URL + "/marge",
|
||||
GroupID: expected.ID, ExpectedGroup: expected,
|
||||
}); err != nil {
|
||||
t.Fatalf("cleanup: %v", err)
|
||||
}
|
||||
|
||||
if deleteCalls != 1 || active {
|
||||
t.Fatalf("external DELETE calls = %d, active = %v; want 1, false", deleteCalls, active)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedStereoPairPersistenceReadsOneCurrentURLSnapshot(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
group := persistenceTestGroup("")
|
||||
groupID, err := ds.AddGroup("OLD-ACCOUNT", group)
|
||||
if err != nil {
|
||||
t.Fatalf("AddGroup: %v", err)
|
||||
}
|
||||
|
||||
currentURL := "http://old.invalid:18000"
|
||||
providerCalls := 0
|
||||
_, preflight, _ := embeddedStereoPairGenerationPersistence(
|
||||
ds,
|
||||
func() []string {
|
||||
providerCalls++
|
||||
return []string{currentURL}
|
||||
},
|
||||
&http.Client{Transport: rejectingRoundTripper{}},
|
||||
)
|
||||
|
||||
currentURL = "http://new.invalid:18000"
|
||||
err = preflight([]stereopair.GenerationRef{
|
||||
{DeviceID: "LEFT-ID", AccountID: "NEW-ACCOUNT", MargeURL: currentURL},
|
||||
{DeviceID: "RIGHT-ID", AccountID: "NEW-ACCOUNT", MargeURL: currentURL},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), groupID) {
|
||||
t.Fatalf("preflight error = %v, want current local generation %s", err, groupID)
|
||||
}
|
||||
if providerCalls != 1 {
|
||||
t.Fatalf("URL provider calls = %d, want one coherent snapshot", providerCalls)
|
||||
}
|
||||
|
||||
err = preflight([]stereopair.GenerationRef{{
|
||||
DeviceID: "LEFT-ID", AccountID: "OLD-ACCOUNT", MargeURL: "http://old.invalid:18000",
|
||||
}})
|
||||
if err == nil || !strings.Contains(err.Error(), "unexpected HTTP persistence request") {
|
||||
t.Fatalf("old URL preflight error = %v, want external HTTP dispatch", err)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ DELETE /accounts/{account}/group/ handlers.(
|
||||
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleUnsupported-fm
|
||||
DELETE /api/control/devices/{id}/ soundtouchweb.(*WebApp).HandleDeleteDevice-fm
|
||||
DELETE /api/control/devices/{id}/library/servers/{account} soundtouchweb.(*WebApp).HandleRemoveLibraryServer-fm
|
||||
DELETE /api/control/devices/{id}/stereo-pair/ soundtouchweb.(*WebApp).HandleDissolveStereoPair-fm
|
||||
DELETE /api/setup/devices/{deviceId} handlers.(*Server).HandleRemoveDevice-fm
|
||||
DELETE /api/setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
|
||||
DELETE /api/setup/interactions/sessions handlers.(*Server).HandleCleanupSessions-fm
|
||||
@@ -44,6 +45,7 @@ GET /api/control/devices/{id}/library/browse soundtouch
|
||||
GET /api/control/devices/{id}/library/servers soundtouchweb.(*WebApp).HandleDeviceLibraryServers-fm
|
||||
GET /api/control/devices/{id}/power-status soundtouchweb.(*WebApp).HandleDevicePowerStatus-fm
|
||||
GET /api/control/devices/{id}/recents soundtouchweb.(*WebApp).HandleDeviceRecents-fm
|
||||
GET /api/control/devices/{id}/stereo-pair/ soundtouchweb.(*WebApp).HandleGetStereoPair-fm
|
||||
GET /api/control/devices/{id}/ws soundtouchweb.(*WebApp).HandleDeviceWebSocket-fm
|
||||
GET /api/control/devices/{id}/zone/ soundtouchweb.(*WebApp).HandleGetZone-fm
|
||||
GET /api/control/devices/{id}/zone/candidates soundtouchweb.(*WebApp).HandleGetZoneCandidates-fm
|
||||
@@ -175,6 +177,7 @@ HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handler
|
||||
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
PATCH /api/control/devices/{id}/stereo-pair/ soundtouchweb.(*WebApp).HandleRenameStereoPair-fm
|
||||
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
POST /accounts/{account}/devices handlers.(*Server).HandleUnsupported-fm
|
||||
@@ -195,6 +198,7 @@ POST /api/control/devices/{id}/providers/radiobrowser/play soundtouch
|
||||
POST /api/control/devices/{id}/providers/tts/play soundtouchweb.(*WebApp).HandleAPISpeakText-fm
|
||||
POST /api/control/devices/{id}/providers/tunein/play soundtouchweb.(*WebApp).HandlePlayTuneIn-fm
|
||||
POST /api/control/devices/{id}/providers/url/play soundtouchweb.(*WebApp).HandlePlayURL-fm
|
||||
POST /api/control/devices/{id}/stereo-pair/ soundtouchweb.(*WebApp).HandleCreateStereoPair-fm
|
||||
POST /api/control/devices/{id}/volume/{volume} soundtouchweb.(*WebApp).HandleDirectVolumeControl-fm
|
||||
POST /api/control/devices/{id}/zone/add/{slaveId} soundtouchweb.(*WebApp).HandleZoneAdd-fm
|
||||
POST /api/control/devices/{id}/zone/dissolve soundtouchweb.(*WebApp).HandleZoneDissolve-fm
|
||||
|
||||
@@ -101,7 +101,7 @@ rename and network/firmware info.
|
||||
|
||||
---
|
||||
|
||||
## 4. Render stereo pairs as a single device (shipped)
|
||||
## 4. Stereo-pair presentation and lifecycle (shipped)
|
||||
|
||||
soundtouch-player projects a valid two-speaker stereo pair (formed via
|
||||
`/addGroup` - see [issue #252](https://github.com/gesellix/Bose-SoundTouch/issues/252))
|
||||
@@ -136,12 +136,59 @@ registry.
|
||||
a member is unavailable or the group reports a non-OK state.
|
||||
- Hide the single-device remove action on a projected pair. Standalone
|
||||
speakers continue to render as before.
|
||||
- For standalone stereo-capable SoundTouch 10 speakers, offer pair creation
|
||||
with an explicit LEFT/master and RIGHT member.
|
||||
- For an existing pair, offer rename and a separately confirmed dissolve
|
||||
action. A dissolve changes speaker group state; it does not delete either
|
||||
physical speaker from the player registry.
|
||||
|
||||
**Note:** Pair lifecycle (create / rename / remove) remains available through
|
||||
the existing client and CLI group operations. The player intentionally does
|
||||
not expose a "Dissolve pair" action yet: its current remove operation deletes
|
||||
one physical registry record rather than performing an atomic pair lifecycle
|
||||
operation.
|
||||
**Lifecycle safety:**
|
||||
- A shared coordinator backs both the CLI and player. It freshly checks both
|
||||
speakers, their L/R capability, current group, and temporary-zone state
|
||||
before a mutation. Pair creation also requires one shared Marge account and
|
||||
backend.
|
||||
- After both create candidates are freshly verified as physically standalone,
|
||||
a fail-closed, read-only persistence barrier checks for a stored group before
|
||||
either speaker is mutated. The embedded service searches every account by
|
||||
device ID; standalone player and CLI query the speakers' current Marge
|
||||
backend. Creation stops and reports the exact stale generation when any
|
||||
record remains; pre-create checks never delete it.
|
||||
- Create sends the asymmetric master/slave payloads required by the speaker
|
||||
state machines, then freshly verifies that both members agree. A partial
|
||||
create is compensated only where the exact group generation returned by
|
||||
that speaker can be proven.
|
||||
- Rename and dissolve update both physical speakers and report a degraded
|
||||
result, including per-member detail, instead of claiming success after a
|
||||
partial transition.
|
||||
- Rename and dissolve carry the group ID displayed to the user and reject a
|
||||
stale request if either speaker now belongs to another generation.
|
||||
- A degraded dissolve retains the last exact L/R topology for a bounded retry.
|
||||
The retry freshly verifies both physical identities and states, and stored
|
||||
persistence must match that full topology before it can be retired.
|
||||
- Legacy Marge teardown callbacks without a group ID are acknowledged without
|
||||
deleting persistent state. After a verified physical dissolve, the embedded
|
||||
player retires the exact generation directly in its datastore; standalone
|
||||
player and CLI use the generation-aware endpoint derived from fresh speaker
|
||||
info. Physical verification and exact persistence cleanup share one
|
||||
coordinator lock, and a cleanup failure is returned as degraded.
|
||||
- Retired group IDs leave their small XML snapshot in the datastore and
|
||||
active/retired IDs are reserved across all accounts, so an account move or
|
||||
stale request cannot match a later physical generation.
|
||||
- Pair mutations are rejected while either member belongs to a temporary
|
||||
multi-room zone. The zone must be dissolved first.
|
||||
|
||||
!!! warning "Run lifecycle operations site-locally"
|
||||
Marge hostnames such as `unifi` are resolved from the caller's site, not
|
||||
from the speaker's site. Create, rename, and dissolve a pair through the
|
||||
Player/service deployment co-located with both speakers and their Marge
|
||||
backend; a cross-site registry entry is not a backend-routing mechanism.
|
||||
|
||||
!!! warning "Datastore downgrade boundary"
|
||||
Once this lifecycle has written a `Group_<id>.retired` snapshot, do not run an
|
||||
older service binary against the same datastore. Older allocators do not
|
||||
reserve these generation IDs globally and can reuse one. Restore both the
|
||||
binary and its pre-lifecycle datastore snapshot for a rollback, or upgrade
|
||||
forward.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -855,6 +855,48 @@ soundtouch-cli --host 192.0.2.10 zone remove --member 192.0.2.12
|
||||
soundtouch-cli --host 192.0.2.10 zone dissolve
|
||||
```
|
||||
|
||||
### Stereo Pair Management
|
||||
|
||||
Create and manage a persistent LEFT/RIGHT pair of two SoundTouch 10 speakers.
|
||||
This is distinct from a temporary multi-room zone. Both speakers must be
|
||||
online, stereo-capable, standalone, and outside any zone before a lifecycle
|
||||
operation. Pair creation also requires both speakers to use the same Marge
|
||||
account and backend. Run lifecycle commands from the site containing both
|
||||
speakers; site-relative Marge names such as `unifi` do not identify a remote
|
||||
site when resolved by the CLI host.
|
||||
|
||||
```bash
|
||||
# Inspect a standalone speaker or either member of a pair
|
||||
soundtouch-cli --host 192.0.2.10 group status
|
||||
|
||||
# Create a pair; the LEFT speaker becomes the master
|
||||
soundtouch-cli group create \
|
||||
--left 192.0.2.10 \
|
||||
--right 192.0.2.11 \
|
||||
--name "Living Room"
|
||||
|
||||
# Rename through either member
|
||||
soundtouch-cli --host 192.0.2.10 group rename --name "Living Room Pair"
|
||||
|
||||
# Dissolve the pair without removing either speaker from AfterTouch
|
||||
soundtouch-cli --host 192.0.2.10 group remove
|
||||
```
|
||||
|
||||
Create, rename, and remove verify fresh state on both speakers. Rename and
|
||||
remove first inspect the current group and carry its ID as a generation guard;
|
||||
if the pair changes before mutation, the operation fails without touching the
|
||||
newer pair. A partial transition is reported as degraded with per-speaker
|
||||
details rather than as a successful operation. A remove attempt carries the
|
||||
last exact L/R topology, freshly verifies both speakers, and retires
|
||||
persistence only if the stored generation still matches it. Before create,
|
||||
the CLI verifies
|
||||
both speakers as standalone, queries their current Marge backend for stale
|
||||
group records, and refuses to mutate either speaker while any record remains.
|
||||
After verified physical cleanup, the CLI removes the exact group ID through the
|
||||
Marge URL and account freshly read from the speaker. A backend cleanup failure
|
||||
is therefore visible as a degraded result instead of leaving an apparently
|
||||
successful stale generation.
|
||||
|
||||
### Browse and Navigation
|
||||
|
||||
Browse and navigate content sources on your device.
|
||||
|
||||
+43
-6
@@ -144,6 +144,7 @@ package client
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -1105,6 +1106,10 @@ func (c *Client) Host() string {
|
||||
|
||||
// get performs a GET request and unmarshals the XML response
|
||||
func (c *Client) get(endpoint string, result interface{}) error {
|
||||
return c.getWithHTTPClient(c.httpClient, endpoint, result)
|
||||
}
|
||||
|
||||
func (c *Client) getWithHTTPClient(httpClient *http.Client, endpoint string, result interface{}) error {
|
||||
url := c.baseURL + endpoint
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
@@ -1115,7 +1120,7 @@ func (c *Client) get(endpoint string, result interface{}) error {
|
||||
req.Header.Set("User-Agent", c.userAgent)
|
||||
req.Header.Set("Accept", "application/xml")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute request: %w", err)
|
||||
}
|
||||
@@ -1151,6 +1156,37 @@ func (c *Client) get(endpoint string, result interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// mutatingGet performs a firmware-required state-changing GET exactly once at
|
||||
// the HTTP transport layer. A fresh connection prevents net/http from
|
||||
// automatically replaying the request after an ambiguous failure on a reused
|
||||
// connection.
|
||||
func (c *Client) mutatingGet(endpoint string, result interface{}) error {
|
||||
baseTransport := c.httpClient.Transport
|
||||
if baseTransport == nil {
|
||||
baseTransport = http.DefaultTransport
|
||||
}
|
||||
|
||||
transport, ok := baseTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return errors.New("state-changing GET requires a cloneable HTTP transport")
|
||||
}
|
||||
|
||||
oneShotTransport := transport.Clone()
|
||||
|
||||
oneShotTransport.DisableKeepAlives = true
|
||||
defer oneShotTransport.CloseIdleConnections()
|
||||
|
||||
oneShotClient := &http.Client{
|
||||
Transport: oneShotTransport,
|
||||
Timeout: c.httpClient.Timeout,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
return c.getWithHTTPClient(oneShotClient, endpoint, result)
|
||||
}
|
||||
|
||||
// post performs a POST request with XML body
|
||||
func (c *Client) post(endpoint string, payload interface{}) error {
|
||||
url := c.baseURL + endpoint
|
||||
@@ -1448,10 +1484,11 @@ func (c *Client) GetGroup() (*models.Group, error) {
|
||||
return &g, err
|
||||
}
|
||||
|
||||
// AddGroup creates a new stereo pair on the device addressed by this client,
|
||||
// which becomes the master. The supplied group must contain both LEFT and
|
||||
// RIGHT roles; the device assigns the group ID and echoes the full state
|
||||
// in the response.
|
||||
// AddGroup applies one side of stereo-pair creation to the addressed device.
|
||||
// The supplied group must contain both LEFT and RIGHT roles. A master-bound
|
||||
// request omits SenderIPAddress; a slave-bound request sets it to the master's
|
||||
// IP address. Firmware may acknowledge the request without returning the
|
||||
// assigned group ID, so callers must verify the resulting state with GetGroup.
|
||||
func (c *Client) AddGroup(group *models.Group) (*models.Group, error) {
|
||||
var result models.Group
|
||||
if err := c.postWithResponse("/addGroup", group, &result); err != nil {
|
||||
@@ -1481,7 +1518,7 @@ func (c *Client) UpdateGroup(group *models.Group) (*models.Group, error) {
|
||||
func (c *Client) RemoveGroup() error {
|
||||
var g models.Group
|
||||
|
||||
return c.get("/removeGroup", &g)
|
||||
return c.mutatingGet("/removeGroup", &g)
|
||||
}
|
||||
|
||||
// SetName sets the device name
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
@@ -232,3 +233,74 @@ func TestClient_RemoveGroup(t *testing.T) {
|
||||
t.Fatalf("RemoveGroup: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRemoveGroupDoesNotReplayDroppedResponse(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/getGroup" {
|
||||
_, _ = w.Write([]byte(`<group />`))
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Path != "/removeGroup" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
calls.Add(1)
|
||||
connection, _, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Errorf("hijack response: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
_ = connection.Close()
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
if _, err := client.GetGroup(); err != nil {
|
||||
t.Fatalf("prime ordinary client connection: %v", err)
|
||||
}
|
||||
|
||||
err := client.RemoveGroup()
|
||||
if err == nil {
|
||||
t.Fatal("RemoveGroup succeeded after the response was dropped")
|
||||
}
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("/removeGroup requests = %d, want exactly 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRemoveGroupDoesNotFollowRedirect(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
var redirectedCalls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/removeGroup":
|
||||
http.Redirect(w, r, "/redirected", http.StatusTemporaryRedirect)
|
||||
case "/redirected":
|
||||
redirectedCalls.Add(1)
|
||||
_, _ = w.Write([]byte(`<group />`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := createTestClient(server.URL).RemoveGroup()
|
||||
if err == nil {
|
||||
t.Fatal("RemoveGroup followed a redirect")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "status 307") {
|
||||
t.Fatalf("RemoveGroup error = %q, want redirect status", err)
|
||||
}
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("HTTP requests = %d, want exactly 1", got)
|
||||
}
|
||||
if got := redirectedCalls.Load(); got != 0 {
|
||||
t.Fatalf("redirect target requests = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,42 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
)
|
||||
|
||||
// ErrGroupNotFound is returned when no group is found for a given device.
|
||||
var ErrGroupNotFound = errors.New("group not found")
|
||||
var (
|
||||
// ErrGroupNotFound is returned when no group is found for a given device.
|
||||
ErrGroupNotFound = errors.New("group not found")
|
||||
// ErrGroupMembershipConflict is returned when a device already belongs to
|
||||
// another stored group.
|
||||
ErrGroupMembershipConflict = errors.New("group membership conflict")
|
||||
// ErrGroupDeleteAmbiguous is returned when a deletion cannot identify
|
||||
// exactly one group generation without risking unrelated group state.
|
||||
ErrGroupDeleteAmbiguous = errors.New("group deletion is ambiguous")
|
||||
)
|
||||
|
||||
// GroupGeneration identifies one active stored group generation.
|
||||
type GroupGeneration struct {
|
||||
Account string
|
||||
ID string
|
||||
}
|
||||
|
||||
// GroupMembershipConflictError reports the exact active generations which
|
||||
// contain devices that callers have freshly verified as standalone.
|
||||
type GroupMembershipConflictError struct {
|
||||
Generations []GroupGeneration
|
||||
}
|
||||
|
||||
func (err *GroupMembershipConflictError) Error() string {
|
||||
generations := make([]string, 0, len(err.Generations))
|
||||
for _, generation := range err.Generations {
|
||||
generations = append(generations, generation.Account+"/"+generation.ID)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s: active group generations %s", ErrGroupMembershipConflict, strings.Join(generations, ", "))
|
||||
}
|
||||
|
||||
// Unwrap allows errors.Is to classify this as ErrGroupMembershipConflict.
|
||||
func (err *GroupMembershipConflictError) Unwrap() error {
|
||||
return ErrGroupMembershipConflict
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
@@ -3153,14 +3187,310 @@ func (ds *DataStore) groupFilePath(account, groupID string) string {
|
||||
return filepath.Join(ds.AccountDevicesDir(account), "Group_"+groupID+".xml")
|
||||
}
|
||||
|
||||
// generateGroupID returns a unique 7-digit group ID that has no existing file.
|
||||
func (ds *DataStore) generateGroupID(account string) string {
|
||||
for {
|
||||
id := fmt.Sprintf("%07d", rand.Int63n(10_000_000)) //nolint:gosec
|
||||
if !ds.rootExists(ds.groupFilePath(account, id)) {
|
||||
return id
|
||||
func (ds *DataStore) retiredGroupFilePath(account, groupID string) string {
|
||||
return filepath.Join(ds.AccountDevicesDir(account), "Group_"+groupID+".retired")
|
||||
}
|
||||
|
||||
type groupFileLocation struct {
|
||||
account string
|
||||
path string
|
||||
}
|
||||
|
||||
type groupGenerationLocations struct {
|
||||
active []groupFileLocation
|
||||
retired []groupFileLocation
|
||||
}
|
||||
|
||||
func groupIDFromFilename(name, suffix string) (string, bool) {
|
||||
if !strings.HasPrefix(name, "Group_") || !strings.HasSuffix(name, suffix) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(name, "Group_"), suffix)
|
||||
|
||||
return id, id != ""
|
||||
}
|
||||
|
||||
func (ds *DataStore) accountNamesNoLock() ([]string, error) {
|
||||
entries, err := ds.rootReadDir(filepath.Join(ds.baseDir, "accounts"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accounts := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
accounts = append(accounts, entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
func (ds *DataStore) loadGroupGenerationLocationsNoLock() (map[string]groupGenerationLocations, error) {
|
||||
accounts, err := ds.accountNamesNoLock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
locations := make(map[string]groupGenerationLocations)
|
||||
|
||||
for _, account := range accounts {
|
||||
dir := ds.AccountDevicesDir(account)
|
||||
|
||||
entries, readErr := ds.rootReadDir(dir)
|
||||
if readErr != nil {
|
||||
if os.IsNotExist(readErr) {
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, readErr
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
location := groupFileLocation{account: account, path: filepath.Join(dir, name)}
|
||||
|
||||
if id, ok := groupIDFromFilename(name, ".xml"); ok {
|
||||
generation := locations[id]
|
||||
generation.active = append(generation.active, location)
|
||||
locations[id] = generation
|
||||
} else if id, ok := groupIDFromFilename(name, ".retired"); ok {
|
||||
generation := locations[id]
|
||||
generation.retired = append(generation.retired, location)
|
||||
locations[id] = generation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return locations, nil
|
||||
}
|
||||
|
||||
// generateGroupID returns a globally unique 7-digit group ID. Active and
|
||||
// retired generations reserve their IDs across every account.
|
||||
func (ds *DataStore) generateGroupID() (string, error) {
|
||||
locations, err := ds.loadGroupGenerationLocationsNoLock()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for attempts := 0; attempts < 128; attempts++ {
|
||||
id := fmt.Sprintf("%07d", rand.Int63n(10_000_000)) //nolint:gosec
|
||||
if _, reserved := locations[id]; !reserved {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Near exhaustion, random selection may repeatedly collide. A deterministic
|
||||
// fallback guarantees progress whenever any ID remains available.
|
||||
for candidate := 0; candidate < 10_000_000; candidate++ {
|
||||
id := fmt.Sprintf("%07d", candidate)
|
||||
if _, reserved := locations[id]; !reserved {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("all stereo group generation IDs are reserved")
|
||||
}
|
||||
|
||||
type storedGroup struct {
|
||||
account string
|
||||
id string
|
||||
path string
|
||||
group models.Group
|
||||
}
|
||||
|
||||
func (ds *DataStore) loadStoredGroupsNoLock(account string) ([]storedGroup, error) {
|
||||
dir := ds.AccountDevicesDir(account)
|
||||
|
||||
entries, err := ds.rootReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groups := make([]storedGroup, 0)
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() || !strings.HasPrefix(name, "Group_") || !strings.HasSuffix(name, ".xml") {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, name)
|
||||
|
||||
data, readErr := ds.rootReadFile(path)
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("read stored group %s: %w", name, readErr)
|
||||
}
|
||||
|
||||
var group models.Group
|
||||
if unmarshalErr := xml.Unmarshal(data, &group); unmarshalErr != nil {
|
||||
return nil, fmt.Errorf("parse stored group %s: %w", name, unmarshalErr)
|
||||
}
|
||||
|
||||
groups = append(groups, storedGroup{
|
||||
account: account,
|
||||
id: strings.TrimSuffix(strings.TrimPrefix(name, "Group_"), ".xml"),
|
||||
path: path,
|
||||
group: group,
|
||||
})
|
||||
}
|
||||
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func (ds *DataStore) loadAllStoredGroupsNoLock() ([]storedGroup, error) {
|
||||
accounts, err := ds.accountNamesNoLock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groups := make([]storedGroup, 0)
|
||||
|
||||
for _, account := range accounts {
|
||||
accountGroups, loadErr := ds.loadStoredGroupsNoLock(account)
|
||||
if loadErr != nil {
|
||||
return nil, loadErr
|
||||
}
|
||||
|
||||
groups = append(groups, accountGroups...)
|
||||
}
|
||||
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func uniqueStoredGroupForGeneration(groups []storedGroup, groupID string) (*storedGroup, error) {
|
||||
matches := make([]storedGroup, 0, 1)
|
||||
|
||||
for i := range groups {
|
||||
if groups[i].id == groupID {
|
||||
matches = append(matches, groups[i])
|
||||
}
|
||||
}
|
||||
|
||||
if len(matches) != 1 {
|
||||
return nil, fmt.Errorf("%w: generation %s has %d matches",
|
||||
ErrGroupDeleteAmbiguous, groupID, len(matches))
|
||||
}
|
||||
|
||||
return &matches[0], nil
|
||||
}
|
||||
|
||||
func validateExpectedGroupGeneration(expected *models.Group, groupID, deviceID string) error {
|
||||
if expected == nil || expected.ID != groupID || expected.MasterDeviceID != deviceID {
|
||||
return fmt.Errorf("%w: generation %s has no exact expected topology", ErrGroupDeleteAmbiguous, groupID)
|
||||
}
|
||||
|
||||
if _, containsDevice := groupDeviceIDs(expected)[deviceID]; !containsDevice {
|
||||
return fmt.Errorf("%w: generation %s expected topology does not contain device %s",
|
||||
ErrGroupDeleteAmbiguous, groupID, deviceID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func stereoRoleDevices(group *models.Group) (left, right string, ok bool) {
|
||||
for _, role := range group.Roles.Roles {
|
||||
switch role.Role {
|
||||
case "LEFT":
|
||||
if left != "" || role.DeviceID == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
left = role.DeviceID
|
||||
case "RIGHT":
|
||||
if right != "" || role.DeviceID == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
right = role.DeviceID
|
||||
}
|
||||
}
|
||||
|
||||
return left, right, left != "" && right != ""
|
||||
}
|
||||
|
||||
func sameStereoPair(a, b *models.Group) bool {
|
||||
if len(a.Roles.Roles) != 2 || len(b.Roles.Roles) != 2 || a.MasterDeviceID != b.MasterDeviceID {
|
||||
return false
|
||||
}
|
||||
|
||||
aLeft, aRight, aOK := stereoRoleDevices(a)
|
||||
bLeft, bRight, bOK := stereoRoleDevices(b)
|
||||
|
||||
return aOK && bOK && aLeft == bLeft && aRight == bRight
|
||||
}
|
||||
|
||||
func sameGroupGeneration(a, b *models.Group) bool {
|
||||
if a == nil || b == nil || a.ID != b.ID || a.Name != b.Name ||
|
||||
a.MasterDeviceID != b.MasterDeviceID || len(a.Roles.Roles) != len(b.Roles.Roles) {
|
||||
return false
|
||||
}
|
||||
|
||||
roles := make(map[string]models.GroupRole, len(a.Roles.Roles))
|
||||
for i := range a.Roles.Roles {
|
||||
role := a.Roles.Roles[i]
|
||||
if _, duplicate := roles[role.Role]; duplicate {
|
||||
return false
|
||||
}
|
||||
|
||||
roles[role.Role] = role
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(b.Roles.Roles))
|
||||
for i := range b.Roles.Roles {
|
||||
role := b.Roles.Roles[i]
|
||||
if _, duplicate := seen[role.Role]; duplicate {
|
||||
return false
|
||||
}
|
||||
|
||||
seen[role.Role] = struct{}{}
|
||||
|
||||
other, found := roles[role.Role]
|
||||
if !found || other.DeviceID != role.DeviceID || other.IPAddress != role.IPAddress {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func groupDeviceIDs(group *models.Group) map[string]struct{} {
|
||||
deviceIDs := make(map[string]struct{}, len(group.Roles.Roles)+1)
|
||||
if group.MasterDeviceID != "" {
|
||||
deviceIDs[group.MasterDeviceID] = struct{}{}
|
||||
}
|
||||
|
||||
for _, role := range group.Roles.Roles {
|
||||
if role.DeviceID != "" {
|
||||
deviceIDs[role.DeviceID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return deviceIDs
|
||||
}
|
||||
|
||||
func groupsShareDevice(a, b *models.Group) bool {
|
||||
aDevices := groupDeviceIDs(a)
|
||||
for deviceID := range groupDeviceIDs(b) {
|
||||
if _, found := aDevices[deviceID]; found {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetGroupForDevice returns the group containing the given device, or nil if ungrouped.
|
||||
@@ -3168,43 +3498,37 @@ func (ds *DataStore) GetGroupForDevice(account, deviceID string) (*models.Group,
|
||||
ds.fileMutex.RLock()
|
||||
defer ds.fileMutex.RUnlock()
|
||||
|
||||
dir := ds.AccountDevicesDir(account)
|
||||
|
||||
entries, err := ds.rootReadDir(dir)
|
||||
groups, err := ds.loadStoredGroupsNoLock(account)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, ErrGroupNotFound
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasPrefix(e.Name(), "Group_") || !strings.HasSuffix(e.Name(), ".xml") {
|
||||
var match *models.Group
|
||||
|
||||
for i := range groups {
|
||||
if _, found := groupDeviceIDs(&groups[i].group)[deviceID]; !found {
|
||||
continue
|
||||
}
|
||||
|
||||
data, readErr := ds.rootReadFile(filepath.Join(dir, e.Name()))
|
||||
if readErr != nil {
|
||||
continue
|
||||
if match != nil {
|
||||
return nil, fmt.Errorf("%w: device %s belongs to multiple active groups",
|
||||
ErrGroupMembershipConflict, deviceID)
|
||||
}
|
||||
|
||||
var g models.Group
|
||||
if unmarshalErr := xml.Unmarshal(data, &g); unmarshalErr != nil {
|
||||
continue
|
||||
}
|
||||
match = &groups[i].group
|
||||
}
|
||||
|
||||
for _, role := range g.Roles.Roles {
|
||||
if role.DeviceID == deviceID {
|
||||
return &g, nil
|
||||
}
|
||||
}
|
||||
if match != nil {
|
||||
return match, nil
|
||||
}
|
||||
|
||||
return nil, ErrGroupNotFound
|
||||
}
|
||||
|
||||
// AddGroup saves a new group to disk and returns its generated ID.
|
||||
// AddGroup saves a new group to disk and returns its generated ID. If the same
|
||||
// master and LEFT/RIGHT assignments are already stored in the requested
|
||||
// account, it returns that group unchanged. A device assigned to any other
|
||||
// stored group in any account is a conflict.
|
||||
func (ds *DataStore) AddGroup(account string, group *models.Group) (string, error) {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
@@ -3214,7 +3538,42 @@ func (ds *DataStore) AddGroup(account string, group *models.Group) (string, erro
|
||||
return "", err
|
||||
}
|
||||
|
||||
id := ds.generateGroupID(account)
|
||||
storedGroups, err := ds.loadAllStoredGroupsNoLock()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var existing *storedGroup
|
||||
|
||||
for i := range storedGroups {
|
||||
stored := &storedGroups[i]
|
||||
if stored.account == account && sameStereoPair(group, &stored.group) {
|
||||
if existing != nil {
|
||||
return "", fmt.Errorf("%w: stereo pair is stored more than once", ErrGroupMembershipConflict)
|
||||
}
|
||||
|
||||
existing = stored
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if groupsShareDevice(group, &stored.group) {
|
||||
return "", fmt.Errorf("%w: a requested device belongs to group %s in account %s",
|
||||
ErrGroupMembershipConflict, stored.id, stored.account)
|
||||
}
|
||||
}
|
||||
|
||||
if existing != nil {
|
||||
*group = existing.group
|
||||
|
||||
return existing.id, nil
|
||||
}
|
||||
|
||||
id, err := ds.generateGroupID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
group.ID = id
|
||||
|
||||
data, err := xml.MarshalIndent(group, "", " ")
|
||||
@@ -3260,24 +3619,296 @@ func (ds *DataStore) ModifyGroup(account, groupID, newName string) (*models.Grou
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
// DeleteGroup removes a group from disk.
|
||||
// DeleteGroup retires a group generation. The renamed XML tombstone prevents a
|
||||
// stale client request from ever matching a later physical generation with the
|
||||
// same seven-digit ID.
|
||||
func (ds *DataStore) DeleteGroup(account, groupID string) error {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
err := ds.rootRemove(ds.groupFilePath(account, groupID))
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("group %s not found", groupID)
|
||||
locations, err := ds.loadGroupGenerationLocationsNoLock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return err
|
||||
generation, found := locations[groupID]
|
||||
if !found {
|
||||
return fmt.Errorf("%w: group %s", ErrGroupNotFound, groupID)
|
||||
}
|
||||
|
||||
if len(generation.active) > 0 && len(generation.retired) > 0 {
|
||||
return fmt.Errorf("%w: generation %s is both active and retired", ErrGroupDeleteAmbiguous, groupID)
|
||||
}
|
||||
|
||||
if len(generation.active) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(generation.active) != 1 || generation.active[0].account != account {
|
||||
return fmt.Errorf("%w: generation %s is not uniquely active in account %s",
|
||||
ErrGroupDeleteAmbiguous, groupID, account)
|
||||
}
|
||||
|
||||
return ds.retireGroupNoLock(account, groupID, generation.active[0].path)
|
||||
}
|
||||
|
||||
// DeleteAllGroupsForAccount removes every Group_*.xml file stored under
|
||||
// account. Speakers send DELETE /streaming/account/{id}/group/ (no group
|
||||
// ID) during stereo-pair teardown; since master and slave may live in
|
||||
// different accounts each speaker deletes its own copy. Returns nil if no
|
||||
// group files are found — idempotent by design.
|
||||
// DeleteGroupGenerationForDevice retires exactly one stored group generation
|
||||
// containing deviceID, regardless of which account currently owns that device.
|
||||
// The active XML must match expected before the atomic rename. A missing or
|
||||
// already-retired generation is an idempotent success; every ambiguity fails
|
||||
// closed.
|
||||
func (ds *DataStore) DeleteGroupGenerationForDevice(
|
||||
deviceID string,
|
||||
groupID string,
|
||||
expected *models.Group,
|
||||
) error {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
if deviceID == "" || groupID == "" {
|
||||
return fmt.Errorf("%w: device ID and group ID are required", ErrGroupDeleteAmbiguous)
|
||||
}
|
||||
|
||||
locations, err := ds.loadGroupGenerationLocationsNoLock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
generation, found := locations[groupID]
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(generation.active) > 0 && len(generation.retired) > 0 {
|
||||
return fmt.Errorf("%w: generation %s is both active and retired", ErrGroupDeleteAmbiguous, groupID)
|
||||
}
|
||||
|
||||
if len(generation.active) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(generation.active) != 1 {
|
||||
return fmt.Errorf("%w: generation %s has %d active files",
|
||||
ErrGroupDeleteAmbiguous, groupID, len(generation.active))
|
||||
}
|
||||
|
||||
if expected == nil || expected.ID != groupID {
|
||||
return fmt.Errorf("%w: generation %s has no exact expected topology", ErrGroupDeleteAmbiguous, groupID)
|
||||
}
|
||||
|
||||
if _, expectedContainsDevice := groupDeviceIDs(expected)[deviceID]; !expectedContainsDevice {
|
||||
return fmt.Errorf("%w: generation %s expected topology does not contain device %s",
|
||||
ErrGroupDeleteAmbiguous, groupID, deviceID)
|
||||
}
|
||||
|
||||
groups, err := ds.loadAllStoredGroupsNoLock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
matches := make([]storedGroup, 0, 1)
|
||||
|
||||
for i := range groups {
|
||||
if groups[i].id == groupID {
|
||||
matches = append(matches, groups[i])
|
||||
}
|
||||
}
|
||||
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
return fmt.Errorf("%w: active generation %s has no readable group", ErrGroupDeleteAmbiguous, groupID)
|
||||
case 1:
|
||||
if matches[0].path != generation.active[0].path {
|
||||
return fmt.Errorf("%w: active generation %s path does not match", ErrGroupDeleteAmbiguous, groupID)
|
||||
}
|
||||
|
||||
if _, containsDevice := groupDeviceIDs(&matches[0].group)[deviceID]; !containsDevice {
|
||||
return fmt.Errorf("%w: generation %s does not contain device %s",
|
||||
ErrGroupDeleteAmbiguous, groupID, deviceID)
|
||||
}
|
||||
|
||||
if !sameGroupGeneration(&matches[0].group, expected) {
|
||||
return fmt.Errorf("%w: generation %s topology does not match",
|
||||
ErrGroupDeleteAmbiguous, groupID)
|
||||
}
|
||||
|
||||
return ds.retireGroupNoLock(matches[0].account, groupID, matches[0].path)
|
||||
default:
|
||||
return fmt.Errorf("%w: generation %s has %d matches",
|
||||
ErrGroupDeleteAmbiguous, groupID, len(matches))
|
||||
}
|
||||
}
|
||||
|
||||
// RenameGroupGenerationForDevice renames exactly one active stored group
|
||||
// generation mastered by deviceID, regardless of which account currently owns
|
||||
// that device. The active XML topology must match expected, but its current
|
||||
// name may differ so retries can repair name drift.
|
||||
func (ds *DataStore) RenameGroupGenerationForDevice(
|
||||
deviceID string,
|
||||
groupID string,
|
||||
expected *models.Group,
|
||||
newName string,
|
||||
) (*models.Group, error) {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
newName = strings.TrimSpace(newName)
|
||||
if deviceID == "" || groupID == "" || newName == "" {
|
||||
return nil, fmt.Errorf("%w: device ID, group ID, and new name are required", ErrGroupDeleteAmbiguous)
|
||||
}
|
||||
|
||||
locations, err := ds.loadGroupGenerationLocationsNoLock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
generation, found := locations[groupID]
|
||||
if !found || len(generation.active) == 0 {
|
||||
return nil, fmt.Errorf("%w: group %s", ErrGroupNotFound, groupID)
|
||||
}
|
||||
|
||||
if len(generation.active) > 0 && len(generation.retired) > 0 {
|
||||
return nil, fmt.Errorf("%w: generation %s is both active and retired", ErrGroupDeleteAmbiguous, groupID)
|
||||
}
|
||||
|
||||
if len(generation.active) != 1 {
|
||||
return nil, fmt.Errorf("%w: generation %s has %d active files",
|
||||
ErrGroupDeleteAmbiguous, groupID, len(generation.active))
|
||||
}
|
||||
|
||||
if validationErr := validateExpectedGroupGeneration(expected, groupID, deviceID); validationErr != nil {
|
||||
return nil, validationErr
|
||||
}
|
||||
|
||||
groups, err := ds.loadAllStoredGroupsNoLock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
match, err := uniqueStoredGroupForGeneration(groups, groupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if match.path != generation.active[0].path {
|
||||
return nil, fmt.Errorf("%w: active generation %s path does not match", ErrGroupDeleteAmbiguous, groupID)
|
||||
}
|
||||
|
||||
if match.group.MasterDeviceID != deviceID {
|
||||
return nil, fmt.Errorf("%w: generation %s is not mastered by device %s",
|
||||
ErrGroupDeleteAmbiguous, groupID, deviceID)
|
||||
}
|
||||
|
||||
expectedWithCurrentName := *expected
|
||||
|
||||
expectedWithCurrentName.Name = match.group.Name
|
||||
if !sameGroupGeneration(&match.group, &expectedWithCurrentName) {
|
||||
return nil, fmt.Errorf("%w: generation %s topology does not match",
|
||||
ErrGroupDeleteAmbiguous, groupID)
|
||||
}
|
||||
|
||||
match.group.Name = newName
|
||||
|
||||
updated, err := xml.MarshalIndent(&match.group, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := ds.atomicWriteFile(match.path, append([]byte(xml.Header), updated...)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &match.group, nil
|
||||
}
|
||||
|
||||
// EnsureNoGroupsForDevices verifies that no active stored group contains any
|
||||
// supplied device ID across all accounts. Callers must supply only device IDs
|
||||
// freshly verified as physically standalone. This check never mutates storage.
|
||||
func (ds *DataStore) EnsureNoGroupsForDevices(deviceIDs []string) error {
|
||||
ds.fileMutex.RLock()
|
||||
defer ds.fileMutex.RUnlock()
|
||||
|
||||
verified := make(map[string]struct{}, len(deviceIDs))
|
||||
for _, deviceID := range deviceIDs {
|
||||
if deviceID == "" {
|
||||
return fmt.Errorf("%w: verified device IDs must not be empty", ErrGroupDeleteAmbiguous)
|
||||
}
|
||||
|
||||
verified[deviceID] = struct{}{}
|
||||
}
|
||||
|
||||
if len(verified) == 0 {
|
||||
return fmt.Errorf("%w: at least one verified device ID is required", ErrGroupDeleteAmbiguous)
|
||||
}
|
||||
|
||||
locations, err := ds.loadGroupGenerationLocationsNoLock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for id, generation := range locations {
|
||||
if len(generation.active) > 1 || len(generation.active) > 0 && len(generation.retired) > 0 {
|
||||
return fmt.Errorf("%w: generation %s has %d active and %d retired files",
|
||||
ErrGroupDeleteAmbiguous, id, len(generation.active), len(generation.retired))
|
||||
}
|
||||
}
|
||||
|
||||
groups, err := ds.loadAllStoredGroupsNoLock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
generations := make([]GroupGeneration, 0)
|
||||
|
||||
for i := range groups {
|
||||
for deviceID := range groupDeviceIDs(&groups[i].group) {
|
||||
if _, found := verified[deviceID]; found {
|
||||
generations = append(generations, GroupGeneration{
|
||||
Account: groups[i].account,
|
||||
ID: groups[i].id,
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(generations) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
sort.Slice(generations, func(i, j int) bool {
|
||||
if generations[i].Account == generations[j].Account {
|
||||
return generations[i].ID < generations[j].ID
|
||||
}
|
||||
|
||||
return generations[i].Account < generations[j].Account
|
||||
})
|
||||
|
||||
return &GroupMembershipConflictError{Generations: generations}
|
||||
}
|
||||
|
||||
func (ds *DataStore) retireGroupNoLock(account, groupID, activePath string) error {
|
||||
retiredPath := ds.retiredGroupFilePath(account, groupID)
|
||||
if _, err := ds.rootStat(retiredPath); err == nil {
|
||||
return fmt.Errorf("%w: generation %s is already retired", ErrGroupDeleteAmbiguous, groupID)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ds.rootRename(activePath, retiredPath); err != nil {
|
||||
return fmt.Errorf("retire group %s: %w", groupID, err)
|
||||
}
|
||||
|
||||
ds.rootSyncDir(filepath.Dir(activePath))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAllGroupsForAccount removes every Group_*.xml file stored under the
|
||||
// account. It is reserved for explicit internal or factory-reset workflows,
|
||||
// not speaker teardown requests without a group ID. Returns nil if no group
|
||||
// files are found.
|
||||
func (ds *DataStore) DeleteAllGroupsForAccount(account string) error {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
@@ -0,0 +1,745 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func lifecycleTestGroup(master, left, right, name string) models.Group {
|
||||
return models.Group{
|
||||
Name: name,
|
||||
MasterDeviceID: master,
|
||||
Roles: models.GroupRoles{Roles: []models.GroupRole{
|
||||
{DeviceID: left, Role: "LEFT"},
|
||||
{DeviceID: right, Role: "RIGHT"},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func countLifecycleGroupFiles(t *testing.T, ds *DataStore, account string) int {
|
||||
t.Helper()
|
||||
|
||||
entries, err := os.ReadDir(ds.AccountDevicesDir(account))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0
|
||||
}
|
||||
|
||||
t.Fatalf("read account devices directory: %v", err)
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasPrefix(entry.Name(), "Group_") && strings.HasSuffix(entry.Name(), ".xml") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
func writeLifecycleGroup(t *testing.T, ds *DataStore, account, groupID string, group models.Group) {
|
||||
t.Helper()
|
||||
|
||||
group.ID = groupID
|
||||
|
||||
data, err := xml.MarshalIndent(&group, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal group %s: %v", groupID, err)
|
||||
}
|
||||
|
||||
if err := ds.rootMkdirAll(ds.AccountDevicesDir(account), 0755); err != nil {
|
||||
t.Fatalf("create account %s devices directory: %v", account, err)
|
||||
}
|
||||
|
||||
if err := ds.atomicWriteFile(ds.groupFilePath(account, groupID), append([]byte(xml.Header), data...)); err != nil {
|
||||
t.Fatalf("write group %s: %v", groupID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupGenerationReservationsAreGlobal(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
|
||||
writeLifecycleGroup(t, ds, "ACCOUNT1", "1234567",
|
||||
lifecycleTestGroup("MASTER1", "MASTER1", "SLAVE1", "Active pair"))
|
||||
|
||||
if err := ds.rootMkdirAll(ds.AccountDevicesDir("ACCOUNT2"), 0755); err != nil {
|
||||
t.Fatalf("create tombstone account: %v", err)
|
||||
}
|
||||
if err := ds.atomicWriteFile(ds.retiredGroupFilePath("ACCOUNT2", "7654321"), []byte("retired\n")); err != nil {
|
||||
t.Fatalf("write cross-account tombstone: %v", err)
|
||||
}
|
||||
|
||||
locations, err := ds.loadGroupGenerationLocationsNoLock()
|
||||
if err != nil {
|
||||
t.Fatalf("load generation reservations: %v", err)
|
||||
}
|
||||
|
||||
if got := locations["1234567"]; len(got.active) != 1 || got.active[0].account != "ACCOUNT1" {
|
||||
t.Fatalf("active reservation = %#v, want ACCOUNT1", got)
|
||||
}
|
||||
if got := locations["7654321"]; len(got.retired) != 1 || got.retired[0].account != "ACCOUNT2" {
|
||||
t.Fatalf("retired reservation = %#v, want ACCOUNT2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddGroupReusesStoredStereoPair(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
const account = "ACCOUNT1"
|
||||
|
||||
original := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Original name")
|
||||
original.SenderIPAddress = "192.0.2.10"
|
||||
|
||||
firstID, err := ds.AddGroup(account, &original)
|
||||
if err != nil {
|
||||
t.Fatalf("add original group: %v", err)
|
||||
}
|
||||
|
||||
retry := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Retry name")
|
||||
retry.Roles.Roles[0].IPAddress = "198.51.100.10"
|
||||
|
||||
retryID, err := ds.AddGroup(account, &retry)
|
||||
if err != nil {
|
||||
t.Fatalf("retry group creation: %v", err)
|
||||
}
|
||||
|
||||
if retryID != firstID {
|
||||
t.Fatalf("retry ID = %q, want stored ID %q", retryID, firstID)
|
||||
}
|
||||
|
||||
if retry.ID != firstID || retry.Name != original.Name || retry.SenderIPAddress != original.SenderIPAddress {
|
||||
t.Fatalf("retry returned %#v, want unchanged stored group %#v", retry, original)
|
||||
}
|
||||
|
||||
if got := countLifecycleGroupFiles(t, ds, account); got != 1 {
|
||||
t.Fatalf("stored group files = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddGroupRejectsExistingDeviceMembership(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
const account = "ACCOUNT1"
|
||||
|
||||
original := lifecycleTestGroup("MASTER1", "MASTER1", "SHARED", "First pair")
|
||||
if _, err := ds.AddGroup(account, &original); err != nil {
|
||||
t.Fatalf("add original group: %v", err)
|
||||
}
|
||||
|
||||
conflicting := lifecycleTestGroup("MASTER2", "MASTER2", "SHARED", "Conflicting pair")
|
||||
_, err := ds.AddGroup(account, &conflicting)
|
||||
if !errors.Is(err, ErrGroupMembershipConflict) {
|
||||
t.Fatalf("conflicting add error = %v, want ErrGroupMembershipConflict", err)
|
||||
}
|
||||
|
||||
if got := countLifecycleGroupFiles(t, ds, account); got != 1 {
|
||||
t.Fatalf("stored group files = %d after conflict, want 1", got)
|
||||
}
|
||||
|
||||
if group, getErr := ds.GetGroupForDevice(account, "SHARED"); getErr != nil || group.ID != original.ID {
|
||||
t.Fatalf("stored group changed after conflict: group=%#v err=%v", group, getErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddGroupRejectsCrossAccountMembership(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requested models.Group
|
||||
}{
|
||||
{
|
||||
name: "same stereo pair",
|
||||
requested: lifecycleTestGroup("MASTER1", "MASTER1", "SLAVE1", "Same pair in another account"),
|
||||
},
|
||||
{
|
||||
name: "shared member",
|
||||
requested: lifecycleTestGroup("MASTER2", "MASTER2", "SLAVE1", "Conflicting pair in another account"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
stored := lifecycleTestGroup("MASTER1", "MASTER1", "SLAVE1", "Stored pair")
|
||||
writeLifecycleGroup(t, ds, "ACCOUNT1", "1234567", stored)
|
||||
|
||||
_, err := ds.AddGroup("ACCOUNT2", &test.requested)
|
||||
if !errors.Is(err, ErrGroupMembershipConflict) {
|
||||
t.Fatalf("cross-account add error = %v, want ErrGroupMembershipConflict", err)
|
||||
}
|
||||
if got := countLifecycleGroupFiles(t, ds, "ACCOUNT1"); got != 1 {
|
||||
t.Fatalf("source account group files = %d after conflict, want 1", got)
|
||||
}
|
||||
if got := countLifecycleGroupFiles(t, ds, "ACCOUNT2"); got != 0 {
|
||||
t.Fatalf("requested account group files = %d after conflict, want 0", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddGroupDoesNotReuseMalformedSuperset(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
const account = "ACCOUNT1"
|
||||
|
||||
original := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Pair")
|
||||
if _, err := ds.AddGroup(account, &original); err != nil {
|
||||
t.Fatalf("add original group: %v", err)
|
||||
}
|
||||
|
||||
malformed := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Pair")
|
||||
malformed.Roles.Roles = append(malformed.Roles.Roles, models.GroupRole{DeviceID: "EXTRA", Role: "CENTER"})
|
||||
if _, err := ds.AddGroup(account, &malformed); !errors.Is(err, ErrGroupMembershipConflict) {
|
||||
t.Fatalf("malformed superset error = %v, want ErrGroupMembershipConflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteGroupGenerationForDevice(t *testing.T) {
|
||||
t.Run("removes only the exact generation containing the device", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
const (
|
||||
account = "ACCOUNT1"
|
||||
deviceID = "SLAVE1"
|
||||
)
|
||||
|
||||
first := lifecycleTestGroup("MASTER1", "MASTER1", "SLAVE1", "First pair")
|
||||
second := lifecycleTestGroup("MASTER2", "MASTER2", "SLAVE2", "Second pair")
|
||||
if _, err := ds.AddGroup(account, &first); err != nil {
|
||||
t.Fatalf("add first group: %v", err)
|
||||
}
|
||||
if _, err := ds.AddGroup(account, &second); err != nil {
|
||||
t.Fatalf("add second group: %v", err)
|
||||
}
|
||||
|
||||
if err := ds.DeleteGroupGenerationForDevice(deviceID, first.ID, &first); err != nil {
|
||||
t.Fatalf("delete exact generation: %v", err)
|
||||
}
|
||||
|
||||
if _, err := ds.GetGroupForDevice(account, deviceID); !errors.Is(err, ErrGroupNotFound) {
|
||||
t.Fatalf("deleted device lookup error = %v, want ErrGroupNotFound", err)
|
||||
}
|
||||
if group, err := ds.GetGroupForDevice(account, "SLAVE2"); err != nil || group.ID != second.ID {
|
||||
t.Fatalf("unrelated group was not preserved: group=%#v err=%v", group, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stale generation is an idempotent no-op", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
const account = "ACCOUNT1"
|
||||
|
||||
group := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Current pair")
|
||||
if _, err := ds.AddGroup(account, &group); err != nil {
|
||||
t.Fatalf("add group: %v", err)
|
||||
}
|
||||
|
||||
if err := ds.DeleteGroupGenerationForDevice("MASTER", "OLDER-ID", nil); err != nil {
|
||||
t.Fatalf("delete stale generation: %v", err)
|
||||
}
|
||||
|
||||
if current, err := ds.GetGroupForDevice(account, "MASTER"); err != nil || current.ID != group.ID {
|
||||
t.Fatalf("current generation changed: group=%#v err=%v", current, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing generation is idempotent", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
if err := ds.DeleteGroupGenerationForDevice("MASTER", "PAIR-ID", nil); err != nil {
|
||||
t.Fatalf("delete missing generation: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ambiguous duplicate generation fails closed", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
const deviceID = "MASTER"
|
||||
|
||||
first := lifecycleTestGroup(deviceID, deviceID, "SLAVE", "First pair")
|
||||
if _, err := ds.AddGroup("ACCOUNT1", &first); err != nil {
|
||||
t.Fatalf("add first group: %v", err)
|
||||
}
|
||||
|
||||
second := lifecycleTestGroup(deviceID, deviceID, "SLAVE", "Second pair")
|
||||
writeLifecycleGroup(t, ds, "ACCOUNT2", first.ID, second)
|
||||
|
||||
err := ds.DeleteGroupGenerationForDevice(deviceID, first.ID, &first)
|
||||
if !errors.Is(err, ErrGroupDeleteAmbiguous) {
|
||||
t.Fatalf("ambiguous delete error = %v, want ErrGroupDeleteAmbiguous", err)
|
||||
}
|
||||
if got := countLifecycleGroupFiles(t, ds, "ACCOUNT1") + countLifecycleGroupFiles(t, ds, "ACCOUNT2"); got != 2 {
|
||||
t.Fatalf("stored group files = %d after ambiguity, want 2", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same ID for another device fails closed", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
group := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Current pair")
|
||||
if _, err := ds.AddGroup("ACCOUNT1", &group); err != nil {
|
||||
t.Fatalf("add group: %v", err)
|
||||
}
|
||||
|
||||
err := ds.DeleteGroupGenerationForDevice("OTHER", group.ID, &group)
|
||||
if !errors.Is(err, ErrGroupDeleteAmbiguous) {
|
||||
t.Fatalf("wrong-device delete error = %v, want ErrGroupDeleteAmbiguous", err)
|
||||
}
|
||||
if !ds.rootExists(ds.groupFilePath("ACCOUNT1", group.ID)) {
|
||||
t.Fatal("wrong-device delete retired the active group")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("submitted topology must match the stored generation", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
stored := lifecycleTestGroup("MASTER", "MASTER", "REAL-SLAVE", "Current pair")
|
||||
if _, err := ds.AddGroup("ACCOUNT1", &stored); err != nil {
|
||||
t.Fatalf("add group: %v", err)
|
||||
}
|
||||
|
||||
submitted := stored
|
||||
submitted.Roles.Roles = append([]models.GroupRole(nil), stored.Roles.Roles...)
|
||||
submitted.Roles.Roles[1].DeviceID = "SUBSTITUTE-SLAVE"
|
||||
err := ds.DeleteGroupGenerationForDevice("MASTER", stored.ID, &submitted)
|
||||
if !errors.Is(err, ErrGroupDeleteAmbiguous) {
|
||||
t.Fatalf("topology mismatch error = %v, want ErrGroupDeleteAmbiguous", err)
|
||||
}
|
||||
if !ds.rootExists(ds.groupFilePath("ACCOUNT1", stored.ID)) {
|
||||
t.Fatal("topology mismatch retired the active group")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRenameGroupGenerationForDevice(t *testing.T) {
|
||||
t.Run("renames an exact generation across accounts", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
|
||||
unrelated := lifecycleTestGroup("OTHER-MASTER", "OTHER-MASTER", "OTHER-SLAVE", "Unrelated pair")
|
||||
if _, err := ds.AddGroup("ACCOUNT1", &unrelated); err != nil {
|
||||
t.Fatalf("add unrelated group: %v", err)
|
||||
}
|
||||
|
||||
group := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Original name")
|
||||
group.Roles.Roles[0].IPAddress = "192.0.2.10"
|
||||
group.Roles.Roles[1].IPAddress = "192.0.2.11"
|
||||
if _, err := ds.AddGroup("ACCOUNT2", &group); err != nil {
|
||||
t.Fatalf("add group: %v", err)
|
||||
}
|
||||
|
||||
updated, err := ds.RenameGroupGenerationForDevice("MASTER", group.ID, &group, "Renamed pair")
|
||||
if err != nil {
|
||||
t.Fatalf("rename exact generation: %v", err)
|
||||
}
|
||||
if updated.ID != group.ID || updated.Name != "Renamed pair" {
|
||||
t.Fatalf("updated group = %#v, want ID %q and renamed name", updated, group.ID)
|
||||
}
|
||||
|
||||
stored, err := ds.GetGroupForDevice("ACCOUNT2", "MASTER")
|
||||
if err != nil || !reflect.DeepEqual(stored, updated) {
|
||||
t.Fatalf("stored renamed group = %#v err=%v, want %#v", stored, err, updated)
|
||||
}
|
||||
if current, err := ds.GetGroupForDevice("ACCOUNT1", "OTHER-MASTER"); err != nil || current.Name != unrelated.Name {
|
||||
t.Fatalf("unrelated group changed: group=%#v err=%v", current, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retry allows the stored name to differ from expected", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
group := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Original name")
|
||||
if _, err := ds.AddGroup("ACCOUNT", &group); err != nil {
|
||||
t.Fatalf("add group: %v", err)
|
||||
}
|
||||
|
||||
if _, err := ds.RenameGroupGenerationForDevice("MASTER", group.ID, &group, "Renamed pair"); err != nil {
|
||||
t.Fatalf("first rename: %v", err)
|
||||
}
|
||||
|
||||
updated, err := ds.RenameGroupGenerationForDevice("MASTER", group.ID, &group, "Renamed pair")
|
||||
if err != nil {
|
||||
t.Fatalf("idempotent rename retry: %v", err)
|
||||
}
|
||||
if updated.Name != "Renamed pair" {
|
||||
t.Fatalf("retry returned name %q, want Renamed pair", updated.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("topology mismatch does not write", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
stored := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Original name")
|
||||
stored.Roles.Roles[0].IPAddress = "192.0.2.10"
|
||||
stored.Roles.Roles[1].IPAddress = "192.0.2.11"
|
||||
if _, err := ds.AddGroup("ACCOUNT", &stored); err != nil {
|
||||
t.Fatalf("add group: %v", err)
|
||||
}
|
||||
|
||||
before, err := ds.rootReadFile(ds.groupFilePath("ACCOUNT", stored.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("read group before rename: %v", err)
|
||||
}
|
||||
|
||||
expected := stored
|
||||
expected.Roles.Roles = append([]models.GroupRole(nil), stored.Roles.Roles...)
|
||||
expected.Roles.Roles[1].IPAddress = "198.51.100.11"
|
||||
_, err = ds.RenameGroupGenerationForDevice("MASTER", stored.ID, &expected, "Renamed pair")
|
||||
if !errors.Is(err, ErrGroupDeleteAmbiguous) {
|
||||
t.Fatalf("topology mismatch error = %v, want ErrGroupDeleteAmbiguous", err)
|
||||
}
|
||||
|
||||
after, err := ds.rootReadFile(ds.groupFilePath("ACCOUNT", stored.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("read group after rename: %v", err)
|
||||
}
|
||||
if !bytes.Equal(after, before) {
|
||||
t.Fatal("topology mismatch rewrote the active group")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unrelated device does not write", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
group := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Original name")
|
||||
if _, err := ds.AddGroup("ACCOUNT", &group); err != nil {
|
||||
t.Fatalf("add group: %v", err)
|
||||
}
|
||||
|
||||
before, err := ds.rootReadFile(ds.groupFilePath("ACCOUNT", group.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("read group before rename: %v", err)
|
||||
}
|
||||
|
||||
_, err = ds.RenameGroupGenerationForDevice("OTHER", group.ID, &group, "Renamed pair")
|
||||
if !errors.Is(err, ErrGroupDeleteAmbiguous) {
|
||||
t.Fatalf("unrelated-device error = %v, want ErrGroupDeleteAmbiguous", err)
|
||||
}
|
||||
|
||||
after, err := ds.rootReadFile(ds.groupFilePath("ACCOUNT", group.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("read group after rename: %v", err)
|
||||
}
|
||||
if !bytes.Equal(after, before) {
|
||||
t.Fatal("unrelated-device rename rewrote the active group")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ambiguous duplicate generation does not write", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
group := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Original name")
|
||||
writeLifecycleGroup(t, ds, "ACCOUNT1", "1234567", group)
|
||||
writeLifecycleGroup(t, ds, "ACCOUNT2", "1234567", group)
|
||||
group.ID = "1234567"
|
||||
|
||||
_, err := ds.RenameGroupGenerationForDevice("MASTER", group.ID, &group, "Renamed pair")
|
||||
if !errors.Is(err, ErrGroupDeleteAmbiguous) {
|
||||
t.Fatalf("ambiguous rename error = %v, want ErrGroupDeleteAmbiguous", err)
|
||||
}
|
||||
|
||||
for _, account := range []string{"ACCOUNT1", "ACCOUNT2"} {
|
||||
stored, getErr := ds.GetGroupForDevice(account, "MASTER")
|
||||
if getErr != nil || stored.Name != "Original name" {
|
||||
t.Fatalf("group in %s changed after ambiguity: group=%#v err=%v", account, stored, getErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty name is rejected", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
group := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Original name")
|
||||
if _, err := ds.AddGroup("ACCOUNT", &group); err != nil {
|
||||
t.Fatalf("add group: %v", err)
|
||||
}
|
||||
|
||||
_, err := ds.RenameGroupGenerationForDevice("MASTER", group.ID, &group, "")
|
||||
if !errors.Is(err, ErrGroupDeleteAmbiguous) {
|
||||
t.Fatalf("empty-name error = %v, want ErrGroupDeleteAmbiguous", err)
|
||||
}
|
||||
if current, getErr := ds.GetGroupForDevice("ACCOUNT", "MASTER"); getErr != nil || current.Name != group.Name {
|
||||
t.Fatalf("group changed after empty name: group=%#v err=%v", current, getErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-master device is rejected", func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
group := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Original name")
|
||||
if _, err := ds.AddGroup("ACCOUNT", &group); err != nil {
|
||||
t.Fatalf("add group: %v", err)
|
||||
}
|
||||
|
||||
_, err := ds.RenameGroupGenerationForDevice("SLAVE", group.ID, &group, "Renamed pair")
|
||||
if !errors.Is(err, ErrGroupDeleteAmbiguous) {
|
||||
t.Fatalf("non-master error = %v, want ErrGroupDeleteAmbiguous", err)
|
||||
}
|
||||
if current, getErr := ds.GetGroupForDevice("ACCOUNT", "MASTER"); getErr != nil || current.Name != group.Name {
|
||||
t.Fatalf("group changed after non-master rename: group=%#v err=%v", current, getErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnsureNoGroupsForDevicesReportsStaleGroupsAcrossAccountsWithoutMutation(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
const (
|
||||
firstID = "1234567"
|
||||
secondID = "7654321"
|
||||
)
|
||||
|
||||
first := lifecycleTestGroup("MOVED", "MOVED", "OLD-SLAVE-1", "First stale pair")
|
||||
writeLifecycleGroup(t, ds, "ACCOUNT1", firstID, first)
|
||||
|
||||
second := lifecycleTestGroup("MOVED", "MOVED", "OLD-SLAVE-2", "Second stale pair")
|
||||
writeLifecycleGroup(t, ds, "ACCOUNT2", secondID, second)
|
||||
|
||||
unrelated := lifecycleTestGroup("OTHER-MASTER", "OTHER-MASTER", "OTHER-SLAVE", "Unrelated pair")
|
||||
if _, err := ds.AddGroup("ACCOUNT3", &unrelated); err != nil {
|
||||
t.Fatalf("add unrelated group: %v", err)
|
||||
}
|
||||
|
||||
if firstID == unrelated.ID || secondID == unrelated.ID {
|
||||
t.Fatalf("active generation IDs are not globally unique: %q %q %q", firstID, secondID, unrelated.ID)
|
||||
}
|
||||
|
||||
firstBefore, err := ds.rootReadFile(ds.groupFilePath("ACCOUNT1", firstID))
|
||||
if err != nil {
|
||||
t.Fatalf("read first group before check: %v", err)
|
||||
}
|
||||
secondBefore, err := ds.rootReadFile(ds.groupFilePath("ACCOUNT2", secondID))
|
||||
if err != nil {
|
||||
t.Fatalf("read second group before check: %v", err)
|
||||
}
|
||||
|
||||
err = ds.EnsureNoGroupsForDevices([]string{"MOVED"})
|
||||
if !errors.Is(err, ErrGroupMembershipConflict) {
|
||||
t.Fatalf("cross-account check error = %v, want ErrGroupMembershipConflict", err)
|
||||
}
|
||||
|
||||
var conflict *GroupMembershipConflictError
|
||||
if !errors.As(err, &conflict) {
|
||||
t.Fatalf("cross-account check error type = %T, want *GroupMembershipConflictError", err)
|
||||
}
|
||||
wantGenerations := []GroupGeneration{
|
||||
{Account: "ACCOUNT1", ID: firstID},
|
||||
{Account: "ACCOUNT2", ID: secondID},
|
||||
}
|
||||
if !reflect.DeepEqual(conflict.Generations, wantGenerations) {
|
||||
t.Fatalf("conflicting generations = %#v, want %#v", conflict.Generations, wantGenerations)
|
||||
}
|
||||
|
||||
firstAfter, err := ds.rootReadFile(ds.groupFilePath("ACCOUNT1", firstID))
|
||||
if err != nil {
|
||||
t.Fatalf("read first group after check: %v", err)
|
||||
}
|
||||
secondAfter, err := ds.rootReadFile(ds.groupFilePath("ACCOUNT2", secondID))
|
||||
if err != nil {
|
||||
t.Fatalf("read second group after check: %v", err)
|
||||
}
|
||||
if !bytes.Equal(firstAfter, firstBefore) || !bytes.Equal(secondAfter, secondBefore) {
|
||||
t.Fatal("read-only group check changed active group data")
|
||||
}
|
||||
if ds.rootExists(ds.retiredGroupFilePath("ACCOUNT1", firstID)) ||
|
||||
ds.rootExists(ds.retiredGroupFilePath("ACCOUNT2", secondID)) {
|
||||
t.Fatal("read-only group check created a tombstone")
|
||||
}
|
||||
if got := countLifecycleGroupFiles(t, ds, "ACCOUNT3"); got != 1 {
|
||||
t.Fatalf("unrelated active group files = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetireGroupAtomicallyRenamesActiveXML(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
const (
|
||||
account = "ACCOUNT1"
|
||||
groupID = "1234567"
|
||||
)
|
||||
|
||||
group := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Pair")
|
||||
writeLifecycleGroup(t, ds, account, groupID, group)
|
||||
|
||||
activePath := ds.groupFilePath(account, groupID)
|
||||
retiredPath := ds.retiredGroupFilePath(account, groupID)
|
||||
activeInfo, err := os.Stat(activePath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat active group: %v", err)
|
||||
}
|
||||
activeXML, err := os.ReadFile(activePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read active group: %v", err)
|
||||
}
|
||||
|
||||
if err := ds.DeleteGroup(account, groupID); err != nil {
|
||||
t.Fatalf("retire group: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(activePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("active path stat error = %v, want not exist", err)
|
||||
}
|
||||
retiredInfo, err := os.Stat(retiredPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat retired group: %v", err)
|
||||
}
|
||||
if !os.SameFile(activeInfo, retiredInfo) {
|
||||
t.Fatal("retired group is not the renamed active file")
|
||||
}
|
||||
retiredXML, err := os.ReadFile(retiredPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read retired group: %v", err)
|
||||
}
|
||||
if !bytes.Equal(retiredXML, activeXML) {
|
||||
t.Fatal("retired group did not preserve the active XML contents")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureNoGroupsForDevicesRejectsActiveTombstoneAmbiguity(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
|
||||
group := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Ambiguous pair")
|
||||
if _, err := ds.AddGroup("ACCOUNT1", &group); err != nil {
|
||||
t.Fatalf("add active group: %v", err)
|
||||
}
|
||||
|
||||
if err := ds.rootMkdirAll(ds.AccountDevicesDir("ACCOUNT2"), 0755); err != nil {
|
||||
t.Fatalf("create tombstone account: %v", err)
|
||||
}
|
||||
if err := ds.atomicWriteFile(ds.retiredGroupFilePath("ACCOUNT2", group.ID), []byte("retired\n")); err != nil {
|
||||
t.Fatalf("write conflicting tombstone: %v", err)
|
||||
}
|
||||
|
||||
err := ds.EnsureNoGroupsForDevices([]string{"MASTER"})
|
||||
if !errors.Is(err, ErrGroupDeleteAmbiguous) {
|
||||
t.Fatalf("ambiguous check error = %v, want ErrGroupDeleteAmbiguous", err)
|
||||
}
|
||||
if !ds.rootExists(ds.groupFilePath("ACCOUNT1", group.ID)) {
|
||||
t.Fatal("ambiguous check removed the active group")
|
||||
}
|
||||
if _, readErr := ds.rootReadFile(ds.retiredGroupFilePath("ACCOUNT2", group.ID)); readErr != nil {
|
||||
t.Fatalf("ambiguous check changed the tombstone: %v", readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupReadsFailClosedOnMalformedOrUnreadableData(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T, ds *DataStore) string
|
||||
}{
|
||||
{
|
||||
name: "malformed XML",
|
||||
setup: func(t *testing.T, ds *DataStore) string {
|
||||
t.Helper()
|
||||
|
||||
path := ds.groupFilePath("ACCOUNT1", "1234567")
|
||||
if err := ds.rootMkdirAll(ds.AccountDevicesDir("ACCOUNT1"), 0755); err != nil {
|
||||
t.Fatalf("create account directory: %v", err)
|
||||
}
|
||||
if err := ds.atomicWriteFile(path, []byte("<group>")); err != nil {
|
||||
t.Fatalf("write malformed group: %v", err)
|
||||
}
|
||||
|
||||
return path
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unreadable group",
|
||||
setup: func(t *testing.T, ds *DataStore) string {
|
||||
t.Helper()
|
||||
|
||||
if err := ds.rootMkdirAll(ds.AccountDevicesDir("ACCOUNT1"), 0755); err != nil {
|
||||
t.Fatalf("create account directory: %v", err)
|
||||
}
|
||||
path := ds.groupFilePath("ACCOUNT1", "1234567")
|
||||
if err := os.Symlink("missing-group-target", path); err != nil {
|
||||
t.Fatalf("create unreadable group symlink: %v", err)
|
||||
}
|
||||
|
||||
return path
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
path := test.setup(t, ds)
|
||||
|
||||
if err := ds.EnsureNoGroupsForDevices([]string{"MASTER"}); err == nil {
|
||||
t.Fatal("EnsureNoGroupsForDevices error = nil, want datastore error")
|
||||
}
|
||||
if _, err := ds.GetGroupForDevice("ACCOUNT1", "MASTER"); err == nil || errors.Is(err, ErrGroupNotFound) {
|
||||
t.Fatalf("GetGroupForDevice error = %v, want datastore error", err)
|
||||
}
|
||||
if _, err := os.Lstat(path); err != nil {
|
||||
t.Fatalf("fail-closed reads mutated group path: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGroupForDeviceFailsClosedOnDuplicateMembership(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
first := lifecycleTestGroup("MASTER1", "MASTER1", "SHARED", "First pair")
|
||||
second := lifecycleTestGroup("MASTER2", "MASTER2", "SHARED", "Second pair")
|
||||
|
||||
writeLifecycleGroup(t, ds, "ACCOUNT1", "1234567", first)
|
||||
writeLifecycleGroup(t, ds, "ACCOUNT1", "7654321", second)
|
||||
|
||||
group, err := ds.GetGroupForDevice("ACCOUNT1", "SHARED")
|
||||
if group != nil || !errors.Is(err, ErrGroupMembershipConflict) {
|
||||
t.Fatalf("group=%#v error=%v, want membership conflict", group, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteGroupClassifiesMissingAndAmbiguousGenerations(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
|
||||
if err := ds.DeleteGroup("ACCOUNT1", "1234567"); !errors.Is(err, ErrGroupNotFound) {
|
||||
t.Fatalf("missing delete error = %v, want ErrGroupNotFound", err)
|
||||
}
|
||||
|
||||
group := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Ambiguous pair")
|
||||
writeLifecycleGroup(t, ds, "ACCOUNT1", "7654321", group)
|
||||
if err := ds.atomicWriteFile(ds.retiredGroupFilePath("ACCOUNT1", "7654321"), []byte("retired\n")); err != nil {
|
||||
t.Fatalf("write conflicting tombstone: %v", err)
|
||||
}
|
||||
|
||||
err := ds.DeleteGroup("ACCOUNT1", "7654321")
|
||||
if !errors.Is(err, ErrGroupDeleteAmbiguous) {
|
||||
t.Fatalf("ambiguous delete error = %v, want ErrGroupDeleteAmbiguous", err)
|
||||
}
|
||||
if !ds.rootExists(ds.groupFilePath("ACCOUNT1", "7654321")) {
|
||||
t.Fatal("ambiguous delete removed the active group")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetiredStereoPairGetsFreshGeneration(t *testing.T) {
|
||||
ds := NewDataStore(t.TempDir())
|
||||
const account = "ACCOUNT1"
|
||||
|
||||
first := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Pair")
|
||||
firstID, err := ds.AddGroup(account, &first)
|
||||
if err != nil {
|
||||
t.Fatalf("add first generation: %v", err)
|
||||
}
|
||||
if err := ds.DeleteGroupGenerationForDevice("MASTER", firstID, &first); err != nil {
|
||||
t.Fatalf("retire first generation: %v", err)
|
||||
}
|
||||
if !ds.rootExists(ds.retiredGroupFilePath(account, firstID)) {
|
||||
t.Fatalf("retired generation %q has no tombstone", firstID)
|
||||
}
|
||||
tombstone, err := ds.rootReadFile(ds.retiredGroupFilePath(account, firstID))
|
||||
if err != nil {
|
||||
t.Fatalf("read retired generation: %v", err)
|
||||
}
|
||||
var retired models.Group
|
||||
if err := xml.Unmarshal(tombstone, &retired); err != nil {
|
||||
t.Fatalf("retired generation does not contain group XML: %v", err)
|
||||
}
|
||||
if retired.ID != firstID {
|
||||
t.Fatalf("retired generation ID = %q, want %q", retired.ID, firstID)
|
||||
}
|
||||
if err := ds.DeleteGroup(account, firstID); err != nil {
|
||||
t.Fatalf("repeat exact generation delete should be idempotent: %v", err)
|
||||
}
|
||||
|
||||
second := lifecycleTestGroup("MASTER", "MASTER", "SLAVE", "Pair")
|
||||
secondID, err := ds.AddGroup(account, &second)
|
||||
if err != nil {
|
||||
t.Fatalf("add second generation: %v", err)
|
||||
}
|
||||
if secondID == firstID {
|
||||
t.Fatalf("new physical generation reused retired ID %q", firstID)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -819,17 +820,21 @@ func (s *Server) HandleMargeDeviceGroup(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
group, err := s.ds.GetGroupForDevice(account, device)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(constants.XMLHeader + `<group/>`))
|
||||
if errors.Is(err, datastore.ErrGroupNotFound) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(constants.XMLHeader + `<group/>`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
data, err := xml.Marshal(group)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(constants.XMLHeader + `<group/>`))
|
||||
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -870,7 +875,13 @@ func (s *Server) HandleMargeAddGroup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
id, err := s.ds.AddGroup(account, &group)
|
||||
if err != nil {
|
||||
if errors.Is(err, datastore.ErrGroupMembershipConflict) {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -937,7 +948,15 @@ func (s *Server) HandleMargeDeleteGroup(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
if err := s.ds.DeleteGroup(account, groupID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
switch {
|
||||
case errors.Is(err, datastore.ErrGroupNotFound):
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
case errors.Is(err, datastore.ErrGroupDeleteAmbiguous):
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
default:
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -946,10 +965,10 @@ func (s *Server) HandleMargeDeleteGroup(w http.ResponseWriter, r *http.Request)
|
||||
_, _ = w.Write([]byte(constants.XMLHeader + `<status>Group deleted successfully</status>`))
|
||||
}
|
||||
|
||||
// HandleMargeDeleteAccountGroups removes all stereo groups stored for an
|
||||
// account. Speakers send DELETE /streaming/account/{id}/group/ (trailing
|
||||
// slash, no group ID) during stereo-pair teardown. Master and slave often
|
||||
// live in different accounts, so each speaker deletes its own copy here.
|
||||
// HandleMargeDeleteAccountGroups acknowledges legacy speaker teardown
|
||||
// callbacks that carry no group ID. Such a request cannot identify a group
|
||||
// generation safely, so it is deliberately non-mutating. Generation-aware
|
||||
// callers use HandleMargeDeleteGroup instead.
|
||||
func (s *Server) HandleMargeDeleteAccountGroups(w http.ResponseWriter, r *http.Request) {
|
||||
account := chi.URLParam(r, "account")
|
||||
|
||||
@@ -958,14 +977,9 @@ func (s *Server) HandleMargeDeleteAccountGroups(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.ds.DeleteAllGroupsForAccount(account); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(constants.XMLHeader + `<status>Group deleted successfully</status>`))
|
||||
_, _ = w.Write([]byte(constants.XMLHeader + `<status>Group teardown acknowledged</status>`))
|
||||
}
|
||||
|
||||
// HandleMusicProviderIsEligible returns the music provider eligibility.
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func margeLifecycleRouter(ds *datastore.DataStore) http.Handler {
|
||||
server := NewServer(ds, nil, "http://localhost:8001", false, false, false)
|
||||
router := chi.NewRouter()
|
||||
router.Use(clientIPMiddleware(false, nil, nil))
|
||||
router.Get("/streaming/account/{account}/device/{device}/group", server.HandleMargeDeviceGroup)
|
||||
router.Post("/streaming/account/{account}/group/", server.HandleMargeAddGroup)
|
||||
router.Delete("/streaming/account/{account}/group/", server.HandleMargeDeleteAccountGroups)
|
||||
router.Delete("/streaming/account/{account}/group/{groupId}", server.HandleMargeDeleteGroup)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func margeLifecycleGroupXML(master, left, right, name string) string {
|
||||
return fmt.Sprintf(`<group><name>%s</name><masterDeviceId>%s</masterDeviceId><roles>`+
|
||||
`<groupRole><deviceId>%s</deviceId><role>LEFT</role></groupRole>`+
|
||||
`<groupRole><deviceId>%s</deviceId><role>RIGHT</role></groupRole>`+
|
||||
`</roles></group>`, name, master, left, right)
|
||||
}
|
||||
|
||||
func margeLifecycleRequest(t *testing.T, handler http.Handler, method, path, remoteAddr, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
request := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
request.RemoteAddr = remoteAddr
|
||||
request.Header.Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
return recorder
|
||||
}
|
||||
|
||||
func margeLifecycleGroup(master, left, right, name string) models.Group {
|
||||
return models.Group{
|
||||
Name: name,
|
||||
MasterDeviceID: master,
|
||||
Roles: models.GroupRoles{Roles: []models.GroupRole{
|
||||
{DeviceID: left, Role: "LEFT"},
|
||||
{DeviceID: right, Role: "RIGHT"},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func countMargeLifecycleGroupFiles(t *testing.T, ds *datastore.DataStore, account string) int {
|
||||
t.Helper()
|
||||
|
||||
entries, err := os.ReadDir(ds.AccountDevicesDir(account))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0
|
||||
}
|
||||
|
||||
t.Fatalf("read account devices directory: %v", err)
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasPrefix(entry.Name(), "Group_") && strings.HasSuffix(entry.Name(), ".xml") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
func TestMargeAddGroupRetryReusesStoredGroup(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
handler := margeLifecycleRouter(ds)
|
||||
const (
|
||||
account = "ACCOUNT1"
|
||||
path = "/streaming/account/" + account + "/group/"
|
||||
)
|
||||
|
||||
first := margeLifecycleRequest(t, handler, http.MethodPost, path, "192.0.2.10:1234",
|
||||
margeLifecycleGroupXML("MASTER", "MASTER", "SLAVE", "Original name"))
|
||||
if first.Code != http.StatusCreated {
|
||||
t.Fatalf("first POST status = %d, want 201; body=%s", first.Code, first.Body.String())
|
||||
}
|
||||
|
||||
var firstGroup models.Group
|
||||
if err := xml.Unmarshal(first.Body.Bytes(), &firstGroup); err != nil {
|
||||
t.Fatalf("decode first response: %v; body=%s", err, first.Body.String())
|
||||
}
|
||||
firstLocation := first.Header().Get("Location")
|
||||
if firstGroup.ID == "" || !strings.HasSuffix(firstLocation, "/group/"+firstGroup.ID) {
|
||||
t.Fatalf("first response ID=%q Location=%q", firstGroup.ID, firstLocation)
|
||||
}
|
||||
|
||||
retry := margeLifecycleRequest(t, handler, http.MethodPost, path, "192.0.2.10:1234",
|
||||
margeLifecycleGroupXML("MASTER", "MASTER", "SLAVE", "Retry name"))
|
||||
if retry.Code != http.StatusCreated {
|
||||
t.Fatalf("retry POST status = %d, want 201; body=%s", retry.Code, retry.Body.String())
|
||||
}
|
||||
|
||||
var retryGroup models.Group
|
||||
if err := xml.Unmarshal(retry.Body.Bytes(), &retryGroup); err != nil {
|
||||
t.Fatalf("decode retry response: %v; body=%s", err, retry.Body.String())
|
||||
}
|
||||
if retryGroup.ID != firstGroup.ID || retryGroup.Name != firstGroup.Name {
|
||||
t.Fatalf("retry group = %#v, want stored group %#v", retryGroup, firstGroup)
|
||||
}
|
||||
if got := retry.Header().Get("Location"); got != firstLocation {
|
||||
t.Fatalf("retry Location = %q, want %q", got, firstLocation)
|
||||
}
|
||||
if got := retry.Header().Get("Content-Type"); got != "application/vnd.bose.streaming-v1.2+xml" {
|
||||
t.Fatalf("retry Content-Type = %q", got)
|
||||
}
|
||||
if got := countMargeLifecycleGroupFiles(t, ds, account); got != 1 {
|
||||
t.Fatalf("stored group files = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeAddGroupMembershipConflictReturns409(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
handler := margeLifecycleRouter(ds)
|
||||
const (
|
||||
account = "ACCOUNT1"
|
||||
path = "/streaming/account/" + account + "/group/"
|
||||
)
|
||||
|
||||
first := margeLifecycleRequest(t, handler, http.MethodPost, path, "192.0.2.10:1234",
|
||||
margeLifecycleGroupXML("MASTER1", "MASTER1", "SHARED", "First pair"))
|
||||
if first.Code != http.StatusCreated {
|
||||
t.Fatalf("first POST status = %d, want 201; body=%s", first.Code, first.Body.String())
|
||||
}
|
||||
|
||||
conflict := margeLifecycleRequest(t, handler, http.MethodPost, path, "192.0.2.20:1234",
|
||||
margeLifecycleGroupXML("MASTER2", "MASTER2", "SHARED", "Conflicting pair"))
|
||||
if conflict.Code != http.StatusConflict {
|
||||
t.Fatalf("conflicting POST status = %d, want 409; body=%s", conflict.Code, conflict.Body.String())
|
||||
}
|
||||
if got := countMargeLifecycleGroupFiles(t, ds, account); got != 1 {
|
||||
t.Fatalf("stored group files = %d after conflict, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeDeviceGroupReturnsEmptyGroupOnlyWhenNotFound(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
response := margeLifecycleRequest(t, margeLifecycleRouter(ds), http.MethodGet,
|
||||
"/streaming/account/ACCOUNT1/device/MASTER/group", "192.0.2.10:1234", "")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("missing group GET status = %d, want 200; body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if got := response.Body.String(); got != constants.XMLHeader+`<group/>` {
|
||||
t.Fatalf("missing group GET body = %q, want empty group", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeDeviceGroupReturns500ForMalformedOrUnreadableData(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T, path string)
|
||||
}{
|
||||
{
|
||||
name: "malformed XML",
|
||||
setup: func(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
|
||||
if err := os.WriteFile(path, []byte("<group>"), 0600); err != nil {
|
||||
t.Fatalf("write malformed group: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unreadable group",
|
||||
setup: func(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
|
||||
if err := os.Symlink("missing-group-target", path); err != nil {
|
||||
t.Fatalf("create unreadable group symlink: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
if err := os.MkdirAll(ds.AccountDevicesDir("ACCOUNT1"), 0755); err != nil {
|
||||
t.Fatalf("create account directory: %v", err)
|
||||
}
|
||||
test.setup(t, filepath.Join(ds.AccountDevicesDir("ACCOUNT1"), "Group_1234567.xml"))
|
||||
|
||||
response := margeLifecycleRequest(t, margeLifecycleRouter(ds), http.MethodGet,
|
||||
"/streaming/account/ACCOUNT1/device/MASTER/group", "192.0.2.10:1234", "")
|
||||
if response.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("invalid group GET status = %d, want 500; body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if strings.Contains(response.Body.String(), "<group/>") {
|
||||
t.Fatalf("invalid group GET returned empty-group success: %s", response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeDeleteAccountGroupsAcknowledgesWithoutDeleting(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
handler := margeLifecycleRouter(ds)
|
||||
const account = "ACCOUNT1"
|
||||
|
||||
group := margeLifecycleGroup("MASTER", "MASTER", "SLAVE", "Current pair")
|
||||
if _, err := ds.AddGroup(account, &group); err != nil {
|
||||
t.Fatalf("add group: %v", err)
|
||||
}
|
||||
|
||||
response := margeLifecycleRequest(t, handler, http.MethodDelete,
|
||||
"/streaming/account/"+account+"/group/", "192.0.2.10:1234", "")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("DELETE status = %d, want 200; body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
|
||||
if current, err := ds.GetGroupForDevice(account, "MASTER"); err != nil || current.ID != group.ID {
|
||||
t.Fatalf("generation-less teardown changed stored group: group=%#v err=%v", current, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeDeleteGroupDoesNotHideAmbiguousActiveGeneration(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(baseDir)
|
||||
handler := margeLifecycleRouter(ds)
|
||||
const account = "ACCOUNT1"
|
||||
|
||||
group := margeLifecycleGroup("MASTER", "MASTER", "SLAVE", "Current pair")
|
||||
if _, err := ds.AddGroup(account, &group); err != nil {
|
||||
t.Fatalf("add group: %v", err)
|
||||
}
|
||||
|
||||
retiredPath := filepath.Join(ds.AccountDevicesDir(account), "Group_"+group.ID+".retired")
|
||||
if err := os.WriteFile(retiredPath, []byte("retired\n"), 0600); err != nil {
|
||||
t.Fatalf("create conflicting tombstone: %v", err)
|
||||
}
|
||||
|
||||
response := margeLifecycleRequest(t, handler, http.MethodDelete,
|
||||
"/streaming/account/"+account+"/group/"+group.ID, "192.0.2.10:1234", "")
|
||||
if response.Code != http.StatusConflict {
|
||||
t.Fatalf("ambiguous DELETE status = %d, want 409; body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if current, err := ds.GetGroupForDevice(account, "MASTER"); err != nil || current.ID != group.ID {
|
||||
t.Fatalf("ambiguous DELETE changed active generation: group=%#v err=%v", current, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeDeleteMissingGroupReturns404(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
response := margeLifecycleRequest(t, margeLifecycleRouter(ds), http.MethodDelete,
|
||||
"/streaming/account/ACCOUNT1/group/MISSING", "192.0.2.10:1234", "")
|
||||
if response.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing DELETE status = %d, want 404; body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,12 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
bmxpkg "github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/stations"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/stereopair"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
@@ -42,6 +44,14 @@ type WebApp struct {
|
||||
// connection, unlike a single application-wide write lock would.
|
||||
WSClients map[*websocket.Conn]*sync.Mutex
|
||||
WSMutex sync.RWMutex
|
||||
// DeviceWSClients mirrors WSClients but for HandleDeviceWebSocket's
|
||||
// per-device status connections (see withDeviceConnWrite). Keyed by the
|
||||
// webSocketWriter interface rather than *websocket.Conn so tests can
|
||||
// register a mock writer the same way production code registers a real
|
||||
// connection. awaitPriorGlobalWebSocketWrites barriers against both
|
||||
// pools uniformly.
|
||||
DeviceWSClients map[webSocketWriter]*sync.Mutex
|
||||
DeviceWSMutex sync.RWMutex
|
||||
// discoveryPublishMu serializes discoveryStatus publications against
|
||||
// each other only (Store + client snapshot + fan-out stay ordered across
|
||||
// concurrent BroadcastDiscoveryStatus calls). It is deliberately NOT
|
||||
@@ -111,6 +121,11 @@ type WebApp struct {
|
||||
// SeedExtraDevices never probe the same still-offline host concurrently.
|
||||
seedMu sync.Mutex
|
||||
|
||||
// StereoPairs coordinates persistent ST10 stereo-pair mutations across
|
||||
// both physical speakers. It is shared for the lifetime of WebApp so its
|
||||
// mutation lock covers concurrent CLI-like requests from every browser.
|
||||
StereoPairs StereoPairLifecycle
|
||||
|
||||
discoveryStatus atomic.Value // stores *webtypes.DiscoveryStatus
|
||||
}
|
||||
|
||||
@@ -147,13 +162,61 @@ type DeviceEntry struct {
|
||||
|
||||
// NewWebApp creates a new WebApp instance for SPA mode
|
||||
func NewWebApp() *WebApp {
|
||||
return &WebApp{
|
||||
devices: make(map[string]*webtypes.DeviceConnection),
|
||||
WSClients: make(map[*websocket.Conn]*sync.Mutex),
|
||||
app := &WebApp{
|
||||
devices: make(map[string]*webtypes.DeviceConnection),
|
||||
WSClients: make(map[*websocket.Conn]*sync.Mutex),
|
||||
DeviceWSClients: make(map[webSocketWriter]*sync.Mutex),
|
||||
Upgrader: websocket.Upgrader{
|
||||
CheckOrigin: checkWebSocketOrigin,
|
||||
},
|
||||
}
|
||||
app.StereoPairs = stereopair.NewWithGenerationLifecyclePersistence(
|
||||
app.stereoPairClient,
|
||||
func(ref stereopair.GenerationRef) error {
|
||||
return stereopair.DeleteMargeGroupGeneration(app.stereoPairPersistenceClient(), ref)
|
||||
},
|
||||
func(refs []stereopair.GenerationRef) error {
|
||||
return stereopair.EnsureMargeNoGroupGenerations(app.stereoPairPersistenceClient(), refs)
|
||||
},
|
||||
func(ref stereopair.GenerationRef, name string) error {
|
||||
return stereopair.RenameMargeGroupGeneration(app.stereoPairPersistenceClient(), ref, name)
|
||||
},
|
||||
)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
func (app *WebApp) stereoPairPersistenceClient() *http.Client {
|
||||
base := app.serviceHTTPClient()
|
||||
if base.Timeout >= stereopair.RequestTimeout {
|
||||
return base
|
||||
}
|
||||
|
||||
configured := *base
|
||||
configured.Timeout = stereopair.RequestTimeout
|
||||
|
||||
return &configured
|
||||
}
|
||||
|
||||
// SetStereoPairGenerationPersistence overrides both exact post-teardown
|
||||
// retirement and the read-only pre-create generation barrier.
|
||||
func (app *WebApp) SetStereoPairGenerationPersistence(
|
||||
cleanup stereopair.GenerationCleanup,
|
||||
preflight stereopair.GenerationPreflight,
|
||||
rename stereopair.GenerationRename,
|
||||
) {
|
||||
app.StereoPairs = stereopair.NewWithGenerationLifecyclePersistence(app.stereoPairClient, cleanup, preflight, rename)
|
||||
}
|
||||
|
||||
// stereoPairClient uses a dedicated long-timeout client. Pair creation can
|
||||
// legitimately span multiple 15-second speaker/Marge retry cycles, while the
|
||||
// ordinary status clients intentionally use a shorter timeout.
|
||||
func (app *WebApp) stereoPairClient(host string) (stereopair.Client, error) {
|
||||
if strings.TrimSpace(host) == "" {
|
||||
return nil, fmt.Errorf("speaker host is empty")
|
||||
}
|
||||
|
||||
return client.NewClient(&client.Config{Host: host, Timeout: stereopair.RequestTimeout}), nil
|
||||
}
|
||||
|
||||
// GetDevice returns the device for id and whether it exists.
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
package soundtouchweb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/stereopair"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// StereoPairLifecycle is the shared mutation boundary used by the web player.
|
||||
// The concrete coordinator serializes operations and verifies both speakers;
|
||||
// the interface keeps HTTP tests independent from physical devices.
|
||||
type StereoPairLifecycle interface {
|
||||
Inspect(memberIPAddress string) (stereopair.Result, error)
|
||||
Create(req stereopair.CreateRequest) (stereopair.Result, error)
|
||||
Rename(req stereopair.RenameRequest) (stereopair.Result, error)
|
||||
Dissolve(req stereopair.DissolveRequest) (stereopair.Result, error)
|
||||
}
|
||||
|
||||
type stereoPairRequest struct {
|
||||
RightID string `json:"rightId"`
|
||||
GroupID string `json:"groupId"`
|
||||
Name string `json:"name"`
|
||||
Group *models.Group `json:"group,omitempty"`
|
||||
}
|
||||
|
||||
type stereoPairResponse struct {
|
||||
Operation string `json:"operation"`
|
||||
Status string `json:"status"`
|
||||
Capable bool `json:"capable"`
|
||||
Paired bool `json:"paired"`
|
||||
Group *models.Group `json:"group,omitempty"`
|
||||
Members []stereoPairMemberResponse `json:"members,omitempty"`
|
||||
PersistenceAttempted bool `json:"persistenceAttempted,omitempty"`
|
||||
PersistenceComplete bool `json:"persistenceComplete,omitempty"`
|
||||
PersistenceError string `json:"persistenceError,omitempty"`
|
||||
}
|
||||
|
||||
type stereoPairMemberResponse struct {
|
||||
IPAddress string `json:"ipAddress"`
|
||||
DeviceID string `json:"deviceId,omitempty"`
|
||||
Reachable bool `json:"reachable"`
|
||||
Verified bool `json:"verified"`
|
||||
Group *models.Group `json:"group,omitempty"`
|
||||
PreflightError string `json:"preflightError,omitempty"`
|
||||
MutationError string `json:"mutationError,omitempty"`
|
||||
VerificationError string `json:"verificationError,omitempty"`
|
||||
CompensationError string `json:"compensationError,omitempty"`
|
||||
CompensationVerified bool `json:"compensationVerified,omitempty"`
|
||||
}
|
||||
|
||||
// HandleGetStereoPair returns a fresh speaker-backed view of one pair or
|
||||
// standalone ST10. Cached player projection is deliberately not consulted.
|
||||
func (app *WebApp) HandleGetStereoPair(w http.ResponseWriter, r *http.Request) {
|
||||
host, conn, ok := app.stereoPairDevice(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := app.StereoPairs.Inspect(host)
|
||||
app.writeStereoPairResult(w, conn.DeviceInfo, result, err)
|
||||
}
|
||||
|
||||
// HandleCreateStereoPair makes {id} the LEFT/master and rightId the
|
||||
// RIGHT/member. The coordinator repeats every precondition against both
|
||||
// physical speakers immediately before mutation.
|
||||
func (app *WebApp) HandleCreateStereoPair(w http.ResponseWriter, r *http.Request) {
|
||||
leftHost, left, ok := app.stereoPairDevice(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req stereoPairRequest
|
||||
if err := decodeStereoPairRequest(r, &req); err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.RightID == "" {
|
||||
app.sendError(w, "rightId is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
right, exists := app.GetDevice(req.RightID)
|
||||
if !exists || right.DeviceInfo == nil {
|
||||
app.sendError(w, "Right speaker not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
rightHost, err := stereoPairIPAddress(req.RightID, right)
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := app.StereoPairs.Create(stereopair.CreateRequest{
|
||||
LeftIPAddress: leftHost,
|
||||
RightIPAddress: rightHost,
|
||||
Name: req.Name,
|
||||
})
|
||||
app.completeStereoPairMutation(w, left.DeviceInfo, result, err)
|
||||
}
|
||||
|
||||
// HandleRenameStereoPair updates the full pair on both physical speakers and
|
||||
// succeeds only after both fresh reads agree on the new name.
|
||||
func (app *WebApp) HandleRenameStereoPair(w http.ResponseWriter, r *http.Request) {
|
||||
host, conn, ok := app.stereoPairDevice(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req stereoPairRequest
|
||||
if err := decodeStereoPairRequest(r, &req); err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
app.sendError(w, "name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.GroupID == "" {
|
||||
app.sendError(w, "groupId is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := app.StereoPairs.Rename(stereopair.RenameRequest{
|
||||
MemberIPAddress: host,
|
||||
ExpectedGroupID: req.GroupID,
|
||||
Name: req.Name,
|
||||
})
|
||||
app.completeStereoPairMutation(w, conn.DeviceInfo, result, err)
|
||||
}
|
||||
|
||||
// HandleDissolveStereoPair removes the pair from both preflighted members and
|
||||
// reports a degraded outcome rather than hiding a partial teardown.
|
||||
func (app *WebApp) HandleDissolveStereoPair(w http.ResponseWriter, r *http.Request) {
|
||||
host, conn, ok := app.stereoPairDevice(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req stereoPairRequest
|
||||
if err := decodeStereoPairRequest(r, &req); err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.GroupID == "" {
|
||||
app.sendError(w, "groupId is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.validateStereoPairRecoverySnapshot(req.Group); err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := app.StereoPairs.Dissolve(stereopair.DissolveRequest{
|
||||
MemberIPAddress: host,
|
||||
ExpectedGroupID: req.GroupID,
|
||||
ExpectedGroup: req.Group,
|
||||
})
|
||||
app.completeStereoPairMutation(w, conn.DeviceInfo, result, err)
|
||||
}
|
||||
|
||||
func (app *WebApp) validateStereoPairRecoverySnapshot(group *models.Group) error {
|
||||
if group == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := range group.Roles.Roles {
|
||||
role := &group.Roles.Roles[i]
|
||||
if net.ParseIP(role.IPAddress) == nil {
|
||||
return errors.New("recovery snapshot has an invalid member IP address")
|
||||
}
|
||||
|
||||
conn, found := app.deviceByStereoPairIPAddress(role.IPAddress)
|
||||
if !found || conn == nil || conn.DeviceInfo == nil || conn.DeviceInfo.DeviceID != role.DeviceID {
|
||||
return errors.New("recovery snapshot does not match the registered speakers")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *WebApp) stereoPairDevice(w http.ResponseWriter, r *http.Request) (string, *webtypes.DeviceConnection, bool) {
|
||||
deviceID := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if deviceID == "" {
|
||||
app.sendError(w, "Device ID required", http.StatusBadRequest)
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
if app.StereoPairs == nil {
|
||||
app.sendError(w, "Stereo-pair lifecycle is unavailable", http.StatusServiceUnavailable)
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
conn, ok := app.GetDevice(deviceID)
|
||||
if !ok || conn.DeviceInfo == nil {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
host, err := stereoPairIPAddress(deviceID, conn)
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusConflict)
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
return host, conn, true
|
||||
}
|
||||
|
||||
func stereoPairIPAddress(deviceID string, conn *webtypes.DeviceConnection) (string, error) {
|
||||
if conn != nil && conn.DeviceInfo != nil {
|
||||
if ip := strings.TrimSpace(conn.DeviceInfo.IPAddress); net.ParseIP(ip) != nil {
|
||||
return ip, nil
|
||||
}
|
||||
}
|
||||
|
||||
if ip := strings.TrimSpace(deviceID); net.ParseIP(ip) != nil {
|
||||
return ip, nil
|
||||
}
|
||||
|
||||
return "", errors.New("device has no valid IP address for stereo pairing")
|
||||
}
|
||||
|
||||
func decodeStereoPairRequest(r *http.Request, req *stereoPairRequest) error {
|
||||
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
|
||||
return errors.New("invalid request body")
|
||||
}
|
||||
|
||||
req.RightID = strings.TrimSpace(req.RightID)
|
||||
req.GroupID = strings.TrimSpace(req.GroupID)
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if len(req.Name) > 64 {
|
||||
return errors.New("name must not exceed 64 characters")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *WebApp) completeStereoPairMutation(
|
||||
w http.ResponseWriter,
|
||||
info *models.DeviceInfo,
|
||||
result stereopair.Result,
|
||||
operationErr error,
|
||||
) {
|
||||
app.applyStereoPairProjection(result)
|
||||
app.awaitPriorGlobalWebSocketWrites()
|
||||
app.writeStereoPairResult(w, info, result, operationErr)
|
||||
app.refreshStereoPairMembersAsync(result)
|
||||
}
|
||||
|
||||
// applyStereoPairProjection publishes the coordinator's final fresh group
|
||||
// reads locally before any follow-up poll starts. ApplyGroupEvent invalidates
|
||||
// older in-flight /getGroup generations, so stale status cannot replace this
|
||||
// newer lifecycle observation.
|
||||
func (app *WebApp) applyStereoPairProjection(result stereopair.Result) {
|
||||
activity := time.Now()
|
||||
|
||||
for i := range result.Members {
|
||||
member := &result.Members[i]
|
||||
if member.Group == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if conn, ok := app.deviceByStereoPairIPAddress(member.IPAddress); ok && conn != nil {
|
||||
conn.ApplyGroupEvent(member.Group, activity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (app *WebApp) refreshStereoPairMembersAsync(result stereopair.Result) {
|
||||
go func() {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
log.Printf("Stereo-pair follow-up refresh failed: %v", recovered)
|
||||
}
|
||||
}()
|
||||
|
||||
app.refreshStereoPairMembers(result)
|
||||
}()
|
||||
}
|
||||
|
||||
func (app *WebApp) refreshStereoPairMembers(result stereopair.Result) {
|
||||
for i := range result.Members {
|
||||
if conn, ok := app.deviceByStereoPairIPAddress(result.Members[i].IPAddress); ok && conn != nil {
|
||||
app.UpdateDeviceStatus(result.Members[i].IPAddress, conn)
|
||||
}
|
||||
}
|
||||
|
||||
app.BroadcastDeviceList()
|
||||
}
|
||||
|
||||
func (app *WebApp) deviceByStereoPairIPAddress(ipAddress string) (*webtypes.DeviceConnection, bool) {
|
||||
if conn, ok := app.GetDevice(ipAddress); ok {
|
||||
return conn, true
|
||||
}
|
||||
|
||||
for _, entry := range app.DeviceSnapshot() {
|
||||
if entry.Device != nil && entry.Device.DeviceInfo != nil && entry.Device.DeviceInfo.IPAddress == ipAddress {
|
||||
return entry.Device, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (app *WebApp) writeStereoPairResult(w http.ResponseWriter, info *models.DeviceInfo, result stereopair.Result, operationErr error) {
|
||||
data := stereoPairResponse{
|
||||
Operation: string(result.Operation),
|
||||
Status: string(result.Status),
|
||||
Capable: stereoPairCapable(info),
|
||||
Paired: result.Group != nil && !result.Group.IsEmpty(),
|
||||
Group: result.Group,
|
||||
Members: make([]stereoPairMemberResponse, 0, len(result.Members)),
|
||||
PersistenceAttempted: result.PersistenceAttempted,
|
||||
PersistenceComplete: result.PersistenceComplete,
|
||||
PersistenceError: errorString(result.PersistenceError),
|
||||
}
|
||||
for i := range result.Members {
|
||||
member := &result.Members[i]
|
||||
data.Members = append(data.Members, stereoPairMemberResponse{
|
||||
IPAddress: member.IPAddress,
|
||||
DeviceID: member.DeviceID,
|
||||
Reachable: member.Reachable,
|
||||
Verified: member.Verified,
|
||||
Group: member.Group,
|
||||
PreflightError: errorString(member.PreflightError),
|
||||
MutationError: errorString(member.MutationError),
|
||||
VerificationError: errorString(member.VerificationError),
|
||||
CompensationError: errorString(member.CompensationError),
|
||||
CompensationVerified: member.CompensationVerified,
|
||||
})
|
||||
}
|
||||
|
||||
status := http.StatusOK
|
||||
|
||||
response := webtypes.APIResponse{Success: operationErr == nil, Data: data}
|
||||
if operationErr != nil {
|
||||
status = stereoPairHTTPStatus(result)
|
||||
response.Error = operationErr.Error()
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
log.Printf("Failed to encode stereo-pair response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func stereoPairHTTPStatus(result stereopair.Result) int {
|
||||
if result.Status == stereopair.StatusDegraded || resultHasStereoPairError(result, stereopair.ErrUnavailable) {
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
|
||||
if resultHasStereoPairError(result, stereopair.ErrInvalidRequest) {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
return http.StatusConflict
|
||||
}
|
||||
|
||||
func resultHasStereoPairError(result stereopair.Result, target error) bool {
|
||||
if errors.Is(result.PersistenceError, target) {
|
||||
return true
|
||||
}
|
||||
|
||||
for i := range result.Members {
|
||||
member := &result.Members[i]
|
||||
for _, candidate := range []error{
|
||||
member.PreflightError,
|
||||
member.MutationError,
|
||||
member.VerificationError,
|
||||
member.CompensationError,
|
||||
} {
|
||||
if errors.Is(candidate, target) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func errorString(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return err.Error()
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
package soundtouchweb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"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"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/stereopair"
|
||||
)
|
||||
|
||||
type fakeStereoPairLifecycle struct {
|
||||
inspectResult stereopair.Result
|
||||
inspectErr error
|
||||
createResult stereopair.Result
|
||||
createErr error
|
||||
renameResult stereopair.Result
|
||||
renameErr error
|
||||
dissolveResult stereopair.Result
|
||||
dissolveErr error
|
||||
|
||||
createRequest stereopair.CreateRequest
|
||||
renameRequest stereopair.RenameRequest
|
||||
dissolveRequest stereopair.DissolveRequest
|
||||
}
|
||||
|
||||
func (f *fakeStereoPairLifecycle) Inspect(string) (stereopair.Result, error) {
|
||||
return f.inspectResult, f.inspectErr
|
||||
}
|
||||
|
||||
func (f *fakeStereoPairLifecycle) Create(req stereopair.CreateRequest) (stereopair.Result, error) {
|
||||
f.createRequest = req
|
||||
return f.createResult, f.createErr
|
||||
}
|
||||
|
||||
func (f *fakeStereoPairLifecycle) Rename(req stereopair.RenameRequest) (stereopair.Result, error) {
|
||||
f.renameRequest = req
|
||||
return f.renameResult, f.renameErr
|
||||
}
|
||||
|
||||
func (f *fakeStereoPairLifecycle) Dissolve(req stereopair.DissolveRequest) (stereopair.Result, error) {
|
||||
f.dissolveRequest = req
|
||||
|
||||
return f.dissolveResult, f.dissolveErr
|
||||
}
|
||||
|
||||
func stereoPairTestApp(lifecycle StereoPairLifecycle) *WebApp {
|
||||
app := NewWebApp()
|
||||
app.StereoPairs = lifecycle
|
||||
for _, speaker := range []struct {
|
||||
host, id, name string
|
||||
}{
|
||||
{"192.0.2.10", "left-id", "Left"},
|
||||
{"192.0.2.11", "right-id", "Right"},
|
||||
} {
|
||||
conn := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{
|
||||
DeviceID: speaker.id,
|
||||
Name: speaker.name,
|
||||
Type: "SoundTouch 10",
|
||||
IPAddress: speaker.host,
|
||||
})
|
||||
app.AddDevice(speaker.host, conn)
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
func decodeStereoPairAPIResponse(t *testing.T, response *httptest.ResponseRecorder) struct {
|
||||
Success bool `json:"success"`
|
||||
Data stereoPairResponse `json:"data"`
|
||||
Error string `json:"error"`
|
||||
} {
|
||||
t.Helper()
|
||||
|
||||
var payload struct {
|
||||
Success bool `json:"success"`
|
||||
Data stereoPairResponse `json:"data"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
func TestHandleGetStereoPairReportsStandaloneCapableSpeaker(t *testing.T) {
|
||||
fake := &fakeStereoPairLifecycle{inspectResult: stereopair.Result{
|
||||
Operation: stereopair.OperationInspect,
|
||||
Status: stereopair.StatusSucceeded,
|
||||
Group: &models.Group{},
|
||||
}}
|
||||
app := stereoPairTestApp(fake)
|
||||
|
||||
request := withChiParams(httptest.NewRequest(http.MethodGet, "/api/control/devices/192.0.2.10/stereo-pair", nil), map[string]string{"id": "192.0.2.10"})
|
||||
response := httptest.NewRecorder()
|
||||
app.HandleGetStereoPair(response, request)
|
||||
|
||||
payload := decodeStereoPairAPIResponse(t, response)
|
||||
if response.Code != http.StatusOK || !payload.Success || !payload.Data.Capable || payload.Data.Paired {
|
||||
t.Fatalf("unexpected standalone response: status=%d payload=%+v", response.Code, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetStereoPairMapsUnavailableSpeakerToBadGateway(t *testing.T) {
|
||||
fake := &fakeStereoPairLifecycle{
|
||||
inspectResult: stereopair.Result{
|
||||
Operation: stereopair.OperationInspect,
|
||||
Status: stereopair.StatusFailed,
|
||||
Members: []stereopair.MemberResult{{
|
||||
IPAddress: "192.0.2.10",
|
||||
PreflightError: fmt.Errorf("%w: timeout", stereopair.ErrUnavailable),
|
||||
}},
|
||||
},
|
||||
inspectErr: &stereopair.Error{Operation: stereopair.OperationInspect, Status: stereopair.StatusFailed},
|
||||
}
|
||||
app := stereoPairTestApp(fake)
|
||||
|
||||
request := withChiParams(httptest.NewRequest(http.MethodGet,
|
||||
"/api/control/devices/192.0.2.10/stereo-pair", nil), map[string]string{"id": "192.0.2.10"})
|
||||
response := httptest.NewRecorder()
|
||||
app.HandleGetStereoPair(response, request)
|
||||
|
||||
if response.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, want 502; body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetStereoPairPreservesDegradedRecoveryGeneration(t *testing.T) {
|
||||
group := &models.Group{ID: "PAIR-ID", Name: "Living Room", MasterDeviceID: "left-id"}
|
||||
fake := &fakeStereoPairLifecycle{
|
||||
inspectResult: stereopair.Result{
|
||||
Operation: stereopair.OperationInspect,
|
||||
Status: stereopair.StatusDegraded,
|
||||
Group: group,
|
||||
Members: []stereopair.MemberResult{
|
||||
{IPAddress: "192.0.2.10", DeviceID: "left-id", Group: group, Verified: true},
|
||||
{IPAddress: "192.0.2.11", DeviceID: "right-id", VerificationError: errors.New("group is empty")},
|
||||
},
|
||||
},
|
||||
inspectErr: &stereopair.Error{Operation: stereopair.OperationInspect, Status: stereopair.StatusDegraded},
|
||||
}
|
||||
app := stereoPairTestApp(fake)
|
||||
|
||||
request := withChiParams(httptest.NewRequest(http.MethodGet,
|
||||
"/api/control/devices/192.0.2.10/stereo-pair", nil), map[string]string{"id": "192.0.2.10"})
|
||||
response := httptest.NewRecorder()
|
||||
app.HandleGetStereoPair(response, request)
|
||||
|
||||
payload := decodeStereoPairAPIResponse(t, response)
|
||||
if response.Code != http.StatusBadGateway || payload.Success || !payload.Data.Paired ||
|
||||
payload.Data.Group == nil || payload.Data.Group.ID != "PAIR-ID" {
|
||||
t.Fatalf("degraded recovery data was lost: status=%d payload=%+v", response.Code, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCreateStereoPairPassesPhysicalHostsAndName(t *testing.T) {
|
||||
group := &models.Group{ID: "1234567", Name: "Office", MasterDeviceID: "left-id"}
|
||||
fake := &fakeStereoPairLifecycle{createResult: stereopair.Result{
|
||||
Operation: stereopair.OperationCreate,
|
||||
Status: stereopair.StatusSucceeded,
|
||||
Group: group,
|
||||
}}
|
||||
app := stereoPairTestApp(fake)
|
||||
|
||||
request := withChiParams(httptest.NewRequest(http.MethodPost,
|
||||
"/api/control/devices/192.0.2.10/stereo-pair",
|
||||
strings.NewReader(`{"rightId":"192.0.2.11","name":" Office "}`)), map[string]string{"id": "192.0.2.10"})
|
||||
response := httptest.NewRecorder()
|
||||
app.HandleCreateStereoPair(response, request)
|
||||
|
||||
if fake.createRequest.LeftIPAddress != "192.0.2.10" || fake.createRequest.RightIPAddress != "192.0.2.11" || fake.createRequest.Name != "Office" {
|
||||
t.Fatalf("unexpected coordinator request: %+v", fake.createRequest)
|
||||
}
|
||||
payload := decodeStereoPairAPIResponse(t, response)
|
||||
if response.Code != http.StatusOK || !payload.Success || !payload.Data.Paired || payload.Data.Group.ID != "1234567" {
|
||||
t.Fatalf("unexpected create response: status=%d payload=%+v", response.Code, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCreateStereoPairRespondsBeforeStatusRefresh(t *testing.T) {
|
||||
refreshStarted := make(chan struct{})
|
||||
releaseRefresh := make(chan struct{})
|
||||
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/now_playing" {
|
||||
close(refreshStarted)
|
||||
<-releaseRefresh
|
||||
}
|
||||
|
||||
http.Error(w, "status unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer speaker.Close()
|
||||
defer close(releaseRefresh)
|
||||
|
||||
group := &models.Group{ID: "1234567", Name: "Office", MasterDeviceID: "left-id"}
|
||||
fake := &fakeStereoPairLifecycle{createResult: stereopair.Result{
|
||||
Operation: stereopair.OperationCreate,
|
||||
Status: stereopair.StatusSucceeded,
|
||||
Group: group,
|
||||
Members: []stereopair.MemberResult{{
|
||||
IPAddress: "192.0.2.10",
|
||||
DeviceID: "left-id",
|
||||
Verified: true,
|
||||
Group: group,
|
||||
}},
|
||||
}}
|
||||
app := stereoPairTestApp(fake)
|
||||
left, _ := app.GetDevice("192.0.2.10")
|
||||
left.Client = client.NewClientFromHost(speaker.URL)
|
||||
|
||||
request := withChiParams(httptest.NewRequest(http.MethodPost,
|
||||
"/api/control/devices/192.0.2.10/stereo-pair",
|
||||
strings.NewReader(`{"rightId":"192.0.2.11","name":"Office"}`)), map[string]string{"id": "192.0.2.10"})
|
||||
response := httptest.NewRecorder()
|
||||
handlerDone := make(chan struct{})
|
||||
go func() {
|
||||
app.HandleCreateStereoPair(response, request)
|
||||
close(handlerDone)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-refreshStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("follow-up status refresh did not start")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-handlerDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("lifecycle response waited for the blocked status refresh")
|
||||
}
|
||||
|
||||
payload := decodeStereoPairAPIResponse(t, response)
|
||||
if response.Code != http.StatusOK || !payload.Success || payload.Data.Group == nil || payload.Data.Group.ID != group.ID {
|
||||
t.Fatalf("unexpected create response: status=%d payload=%+v", response.Code, payload)
|
||||
}
|
||||
if projected := left.Status().Group; projected == nil || projected.ID != group.ID {
|
||||
t.Fatalf("coordinator projection was not applied before refresh: %+v", projected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyStereoPairProjectionSupersedesInFlightGroupRefresh(t *testing.T) {
|
||||
app := stereoPairTestApp(&fakeStereoPairLifecycle{})
|
||||
left, _ := app.GetDevice("192.0.2.10")
|
||||
staleGeneration := left.BeginGroupRefresh()
|
||||
newGroup := &models.Group{ID: "pair-new", MasterDeviceID: "left-id"}
|
||||
|
||||
app.applyStereoPairProjection(stereopair.Result{Members: []stereopair.MemberResult{{
|
||||
IPAddress: "192.0.2.10",
|
||||
Group: newGroup,
|
||||
}}})
|
||||
|
||||
if left.ApplyPolledGroup(staleGeneration, &models.Group{ID: "pair-old"}) {
|
||||
t.Fatal("older status refresh replaced the lifecycle projection")
|
||||
}
|
||||
if projected := left.Status().Group; projected == nil || projected.ID != newGroup.ID {
|
||||
t.Fatalf("projected group = %+v, want %q", projected, newGroup.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStereoPairResponseWaitsForPreMutationWebSocketWriter(t *testing.T) {
|
||||
app := stereoPairTestApp(&fakeStereoPairLifecycle{})
|
||||
app.webSocketWriteTimeout = 50 * time.Millisecond
|
||||
blockedWriter := &deadlineBlockingWebSocketWriter{started: make(chan struct{})}
|
||||
app.registerDeviceWebSocketClient(blockedWriter)
|
||||
defer app.removeDeviceWebSocketClient(blockedWriter)
|
||||
|
||||
writerDone := make(chan struct{})
|
||||
go func() {
|
||||
_ = app.withDeviceConnWrite(blockedWriter, func(batch webSocketWriteBatch) error {
|
||||
return batch.writeJSON(blockedWriter, struct{}{})
|
||||
})
|
||||
close(writerDone)
|
||||
}()
|
||||
<-blockedWriter.started
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
responseDone := make(chan struct{})
|
||||
group := &models.Group{ID: "PAIR-ID", MasterDeviceID: "left-id"}
|
||||
left, _ := app.GetDevice("192.0.2.10")
|
||||
go func() {
|
||||
app.completeStereoPairMutation(response, left.DeviceInfo, stereopair.Result{
|
||||
Operation: stereopair.OperationCreate,
|
||||
Status: stereopair.StatusSucceeded,
|
||||
Group: group,
|
||||
Members: []stereopair.MemberResult{{
|
||||
IPAddress: "192.0.2.10",
|
||||
DeviceID: "left-id",
|
||||
Group: group,
|
||||
}},
|
||||
}, nil)
|
||||
close(responseDone)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-responseDone:
|
||||
t.Fatal("lifecycle response overtook a pre-mutation WebSocket writer")
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
|
||||
select {
|
||||
case <-writerDone:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("stalled pre-mutation WebSocket writer ignored its batch deadline")
|
||||
}
|
||||
select {
|
||||
case <-responseDone:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("lifecycle response did not pass the WebSocket ordering barrier")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCreateStereoPairUsesDeviceInfoIPsForHostnameRegistryKeys(t *testing.T) {
|
||||
fake := &fakeStereoPairLifecycle{createResult: stereopair.Result{
|
||||
Operation: stereopair.OperationCreate,
|
||||
Status: stereopair.StatusSucceeded,
|
||||
Group: &models.Group{ID: "1234567"},
|
||||
}}
|
||||
app := NewWebApp()
|
||||
app.StereoPairs = fake
|
||||
for _, speaker := range []struct {
|
||||
key, ip, id string
|
||||
}{
|
||||
{"left.example.test", "192.0.2.10", "left-id"},
|
||||
{"right.example.test", "192.0.2.11", "right-id"},
|
||||
} {
|
||||
app.AddDevice(speaker.key, webtypes.NewDeviceConnection(nil, &models.DeviceInfo{
|
||||
DeviceID: speaker.id, Type: "ST10", IPAddress: speaker.ip,
|
||||
}))
|
||||
}
|
||||
|
||||
request := withChiParams(httptest.NewRequest(http.MethodPost,
|
||||
"/api/control/devices/left.example.test/stereo-pair",
|
||||
strings.NewReader(`{"rightId":"right.example.test","name":"Office"}`)), map[string]string{"id": "left.example.test"})
|
||||
response := httptest.NewRecorder()
|
||||
app.HandleCreateStereoPair(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d; body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
payload := decodeStereoPairAPIResponse(t, response)
|
||||
if !payload.Data.Capable {
|
||||
t.Fatal("ST10 model spelling was not reported as stereo capable")
|
||||
}
|
||||
if fake.createRequest.LeftIPAddress != "192.0.2.10" || fake.createRequest.RightIPAddress != "192.0.2.11" {
|
||||
t.Fatalf("coordinator addresses = %+v", fake.createRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRenameStereoPairPassesExpectedGeneration(t *testing.T) {
|
||||
fake := &fakeStereoPairLifecycle{renameResult: stereopair.Result{
|
||||
Operation: stereopair.OperationRename,
|
||||
Status: stereopair.StatusSucceeded,
|
||||
Group: &models.Group{ID: "PAIR-ID", Name: "Office"},
|
||||
}}
|
||||
app := stereoPairTestApp(fake)
|
||||
|
||||
request := withChiParams(httptest.NewRequest(http.MethodPatch,
|
||||
"/api/control/devices/192.0.2.10/stereo-pair",
|
||||
strings.NewReader(`{"groupId":" PAIR-ID ","name":" Office "}`)), map[string]string{"id": "192.0.2.10"})
|
||||
response := httptest.NewRecorder()
|
||||
app.HandleRenameStereoPair(response, request)
|
||||
|
||||
if fake.renameRequest.ExpectedGroupID != "PAIR-ID" || fake.renameRequest.Name != "Office" {
|
||||
t.Fatalf("rename request = %+v", fake.renameRequest)
|
||||
}
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d; body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRenameStereoPairRequiresExpectedGeneration(t *testing.T) {
|
||||
fake := &fakeStereoPairLifecycle{}
|
||||
app := stereoPairTestApp(fake)
|
||||
|
||||
request := withChiParams(httptest.NewRequest(http.MethodPatch,
|
||||
"/api/control/devices/192.0.2.10/stereo-pair",
|
||||
strings.NewReader(`{"name":"Office"}`)), map[string]string{"id": "192.0.2.10"})
|
||||
response := httptest.NewRecorder()
|
||||
app.HandleRenameStereoPair(response, request)
|
||||
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDissolveStereoPairPreservesDegradedDetails(t *testing.T) {
|
||||
verifyErr := errors.New("right speaker still reports the pair")
|
||||
fake := &fakeStereoPairLifecycle{dissolveResult: stereopair.Result{
|
||||
Operation: stereopair.OperationDissolve,
|
||||
Status: stereopair.StatusDegraded,
|
||||
Members: []stereopair.MemberResult{{
|
||||
IPAddress: "192.0.2.11",
|
||||
DeviceID: "right-id",
|
||||
VerificationError: verifyErr,
|
||||
}},
|
||||
}, dissolveErr: &stereopair.Error{Operation: stereopair.OperationDissolve, Status: stereopair.StatusDegraded}}
|
||||
app := stereoPairTestApp(fake)
|
||||
|
||||
request := withChiParams(httptest.NewRequest(http.MethodDelete,
|
||||
"/api/control/devices/192.0.2.10/stereo-pair",
|
||||
strings.NewReader(`{"groupId":"PAIR-ID"}`)), map[string]string{"id": "192.0.2.10"})
|
||||
response := httptest.NewRecorder()
|
||||
app.HandleDissolveStereoPair(response, request)
|
||||
|
||||
if fake.dissolveRequest.ExpectedGroupID != "PAIR-ID" {
|
||||
t.Fatalf("dissolve request = %+v", fake.dissolveRequest)
|
||||
}
|
||||
|
||||
payload := decodeStereoPairAPIResponse(t, response)
|
||||
if response.Code != http.StatusBadGateway || payload.Success || len(payload.Data.Members) != 1 ||
|
||||
payload.Data.Members[0].VerificationError != verifyErr.Error() {
|
||||
t.Fatalf("unexpected degraded response: status=%d payload=%+v", response.Code, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDissolveStereoPairPassesExactRecoverySnapshot(t *testing.T) {
|
||||
fake := &fakeStereoPairLifecycle{dissolveResult: stereopair.Result{
|
||||
Operation: stereopair.OperationDissolve,
|
||||
Status: stereopair.StatusSucceeded,
|
||||
Group: &models.Group{},
|
||||
}}
|
||||
app := stereoPairTestApp(fake)
|
||||
body := `{"groupId":"PAIR-ID","group":{"ID":"PAIR-ID","Name":"Office","MasterDeviceID":"left-id","Roles":{"Roles":[{"DeviceID":"left-id","Role":"LEFT","IPAddress":"192.0.2.10"},{"DeviceID":"right-id","Role":"RIGHT","IPAddress":"192.0.2.11"}]}}}`
|
||||
|
||||
request := withChiParams(httptest.NewRequest(http.MethodDelete,
|
||||
"/api/control/devices/192.0.2.10/stereo-pair", strings.NewReader(body)),
|
||||
map[string]string{"id": "192.0.2.10"})
|
||||
response := httptest.NewRecorder()
|
||||
app.HandleDissolveStereoPair(response, request)
|
||||
|
||||
group := fake.dissolveRequest.ExpectedGroup
|
||||
if response.Code != http.StatusOK || group == nil || group.ID != "PAIR-ID" ||
|
||||
group.MasterDeviceID != "left-id" || len(group.Roles.Roles) != 2 {
|
||||
t.Fatalf("status=%d dissolve request=%+v", response.Code, fake.dissolveRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDissolveStereoPairRejectsUnregisteredSnapshotMember(t *testing.T) {
|
||||
fake := &fakeStereoPairLifecycle{}
|
||||
app := stereoPairTestApp(fake)
|
||||
body := `{"groupId":"PAIR-ID","group":{"ID":"PAIR-ID","MasterDeviceID":"left-id","Roles":{"Roles":[{"DeviceID":"left-id","Role":"LEFT","IPAddress":"192.0.2.10"},{"DeviceID":"substitute-id","Role":"RIGHT","IPAddress":"192.0.2.99"}]}}}`
|
||||
|
||||
request := withChiParams(httptest.NewRequest(http.MethodDelete,
|
||||
"/api/control/devices/192.0.2.10/stereo-pair", strings.NewReader(body)),
|
||||
map[string]string{"id": "192.0.2.10"})
|
||||
response := httptest.NewRecorder()
|
||||
app.HandleDissolveStereoPair(response, request)
|
||||
|
||||
if response.Code != http.StatusConflict || fake.dissolveRequest.ExpectedGroup != nil {
|
||||
t.Fatalf("status=%d coordinator request=%+v", response.Code, fake.dissolveRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDissolveStereoPairReportsCompletedPersistence(t *testing.T) {
|
||||
fake := &fakeStereoPairLifecycle{dissolveResult: stereopair.Result{
|
||||
Operation: stereopair.OperationDissolve,
|
||||
Status: stereopair.StatusSucceeded,
|
||||
Group: &models.Group{},
|
||||
PersistenceAttempted: true,
|
||||
PersistenceComplete: true,
|
||||
}}
|
||||
app := stereoPairTestApp(fake)
|
||||
|
||||
request := withChiParams(httptest.NewRequest(http.MethodDelete,
|
||||
"/api/control/devices/192.0.2.10/stereo-pair",
|
||||
strings.NewReader(`{"groupId":"PAIR-ID"}`)), map[string]string{"id": "192.0.2.10"})
|
||||
response := httptest.NewRecorder()
|
||||
app.HandleDissolveStereoPair(response, request)
|
||||
|
||||
payload := decodeStereoPairAPIResponse(t, response)
|
||||
if response.Code != http.StatusOK || !payload.Success ||
|
||||
!payload.Data.PersistenceAttempted || !payload.Data.PersistenceComplete {
|
||||
t.Fatalf("status=%d payload=%+v", response.Code, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDissolveStereoPairReportsPersistenceFailure(t *testing.T) {
|
||||
fake := &fakeStereoPairLifecycle{dissolveResult: stereopair.Result{
|
||||
Operation: stereopair.OperationDissolve,
|
||||
Status: stereopair.StatusDegraded,
|
||||
Group: &models.Group{},
|
||||
PersistenceAttempted: true,
|
||||
PersistenceError: errors.New("datastore unavailable"),
|
||||
}, dissolveErr: &stereopair.Error{Operation: stereopair.OperationDissolve, Status: stereopair.StatusDegraded}}
|
||||
app := stereoPairTestApp(fake)
|
||||
|
||||
request := withChiParams(httptest.NewRequest(http.MethodDelete,
|
||||
"/api/control/devices/192.0.2.10/stereo-pair",
|
||||
strings.NewReader(`{"groupId":"PAIR-ID"}`)), map[string]string{"id": "192.0.2.10"})
|
||||
response := httptest.NewRecorder()
|
||||
app.HandleDissolveStereoPair(response, request)
|
||||
|
||||
payload := decodeStereoPairAPIResponse(t, response)
|
||||
if response.Code != http.StatusBadGateway || payload.Success ||
|
||||
payload.Data.Status != string(stereopair.StatusDegraded) ||
|
||||
payload.Data.PersistenceError != "datastore unavailable" {
|
||||
t.Fatalf("unexpected persistence response: status=%d payload=%+v", response.Code, payload)
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,23 @@ func TestNewWebApp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStereoPairPersistenceClientHasBoundedLifecycleTimeout(t *testing.T) {
|
||||
app := NewWebApp()
|
||||
transport := &http.Transport{}
|
||||
app.ServiceClient = &http.Client{Transport: transport, Timeout: 10 * time.Second}
|
||||
|
||||
configured := app.stereoPairPersistenceClient()
|
||||
if configured.Timeout != 45*time.Second {
|
||||
t.Fatalf("timeout = %s, want 45s", configured.Timeout)
|
||||
}
|
||||
if configured.Transport != transport {
|
||||
t.Fatal("custom service transport was not preserved")
|
||||
}
|
||||
if app.ServiceClient.Timeout != 10*time.Second {
|
||||
t.Fatalf("source client timeout was mutated to %s", app.ServiceClient.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIDevices(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
|
||||
@@ -93,6 +93,13 @@ func (app *WebApp) MountWeb(r chi.Router, discoveryService *discovery.UnifiedDis
|
||||
r.Post("/leave", app.HandleZoneLeave)
|
||||
})
|
||||
|
||||
r.Route("/stereo-pair", func(r chi.Router) {
|
||||
r.Get("/", app.HandleGetStereoPair)
|
||||
r.Post("/", app.HandleCreateStereoPair)
|
||||
r.Patch("/", app.HandleRenameStereoPair)
|
||||
r.Delete("/", app.HandleDissolveStereoPair)
|
||||
})
|
||||
|
||||
// Play a result from a content provider on this device.
|
||||
// Browsable providers (tunein, radiobrowser) take a catalog item;
|
||||
// input providers (url, tts) take the raw input.
|
||||
|
||||
@@ -72,6 +72,7 @@ func TestMountWebControlAPIShape(t *testing.T) {
|
||||
"/api/control/devices/{id}/providers/radiobrowser/play",
|
||||
"/api/control/devices/{id}/providers/url/play",
|
||||
"/api/control/devices/{id}/providers/tts/play",
|
||||
"/api/control/devices/{id}/stereo-pair/",
|
||||
}
|
||||
for _, want := range mustExist {
|
||||
if !registered[want] {
|
||||
|
||||
@@ -701,6 +701,46 @@ img { display: block; max-width: 100%; }
|
||||
.zone-actions { display: flex; gap: .5rem; margin-top: .25rem; flex-wrap: wrap; }
|
||||
.zone-btn { font-size: .8rem; padding: .3rem .7rem; }
|
||||
|
||||
/* ── Stereo pairs ─────────────────────────────────────────────────────────── */
|
||||
.stereo-pair-section { margin-top: 1.25rem; }
|
||||
.stereo-pair-members { display: flex; flex-direction: column; gap: .3rem; }
|
||||
.stereo-pair-member {
|
||||
display: flex; align-items: center; gap: .6rem;
|
||||
min-height: 2.25rem; padding: .4rem .6rem;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.stereo-role {
|
||||
min-width: 3rem; padding: .15rem .4rem; border-radius: 3px;
|
||||
background: var(--border); color: var(--text-dim);
|
||||
font-size: .65rem; font-weight: 700; text-align: center; letter-spacing: 0;
|
||||
}
|
||||
.stereo-member-name { flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: .875rem; }
|
||||
.stereo-pair-actions { display: flex; align-items: end; gap: .5rem; margin-top: .6rem; flex-wrap: wrap; }
|
||||
.stereo-name-field { display: flex; flex: 1 1 14rem; flex-direction: column; gap: .2rem; }
|
||||
.stereo-name-field span, .picker-label { font-size: .75rem; color: var(--text-dim); letter-spacing: 0; }
|
||||
.stereo-name-field input {
|
||||
min-width: 0; padding: .4rem .55rem; border: 1px solid var(--border);
|
||||
border-radius: var(--radius); background: var(--surface); color: var(--text);
|
||||
font: inherit; font-size: .875rem;
|
||||
}
|
||||
.stereo-name-field input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.stereo-action { min-height: 2.2rem; font-size: .8rem; padding: .35rem .7rem; }
|
||||
.stereo-action.danger { color: #b42318; border-color: #d92d20; }
|
||||
.stereo-pair-standalone { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; color: var(--text-dim); }
|
||||
.stereo-pair-error { margin-bottom: .6rem; color: #b42318; font-size: .8rem; }
|
||||
.stereo-picker { width: min(22rem, calc(100vw - 2rem)); max-width: 22rem; }
|
||||
.picker-name-field { margin: .75rem 0; }
|
||||
.picker-label { margin-bottom: .4rem; }
|
||||
.picker-device-btn.selected { border-color: var(--accent); background: var(--bg); }
|
||||
.stereo-picker-actions { display: flex; justify-content: flex-end; gap: .5rem; }
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.stereo-pair-actions { align-items: stretch; }
|
||||
.stereo-name-field { flex-basis: 100%; }
|
||||
.stereo-action { flex: 1; }
|
||||
}
|
||||
|
||||
/* ── Recents ─────────────────────────────────────────────────────────────── */
|
||||
.recents-section { margin-top: 1.25rem; }
|
||||
|
||||
|
||||
@@ -25,6 +25,22 @@ export const api = {
|
||||
zoneRemove: (masterId, slaveId) => req(`/api/control/devices/${masterId}/zone/remove/${slaveId}`, { method: 'POST' }),
|
||||
zoneDissolve: (id) => req(`/api/control/devices/${id}/zone/dissolve`, { method: 'POST' }),
|
||||
zoneLeave: (id) => req(`/api/control/devices/${id}/zone/leave`, { method: 'POST' }),
|
||||
stereoPair: (id) => req(`/api/control/devices/${id}/stereo-pair/`),
|
||||
stereoPairCreate: (leftId, rightId, name) => req(`/api/control/devices/${leftId}/stereo-pair/`, {
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ rightId, name }),
|
||||
}),
|
||||
stereoPairRename: (id, groupId, name) => req(`/api/control/devices/${id}/stereo-pair/`, {
|
||||
method: 'PATCH',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ groupId, name }),
|
||||
}),
|
||||
stereoPairDissolve: (id, groupId, group) => req(`/api/control/devices/${id}/stereo-pair/`, {
|
||||
method: 'DELETE',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ groupId, group }),
|
||||
}),
|
||||
play: (id, item) => req(`/api/control/devices/${id}/play`, {
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Controls } from './components/Controls.js';
|
||||
import { Presets } from './components/Presets.js';
|
||||
import { Sources } from './components/Sources.js';
|
||||
import { Zone } from './components/Zone.js';
|
||||
import { StereoPair } from './components/StereoPair.js';
|
||||
import { Recents } from './components/Recents.js';
|
||||
import { TuneInBrowser } from './components/TuneInBrowser.js';
|
||||
import { RadioBrowser } from './components/RadioBrowser.js';
|
||||
@@ -18,7 +19,7 @@ import { api } from './api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
function DeviceDetail({ deviceId, devices, onBack }) {
|
||||
function DeviceDetail({ deviceId, devices, onBack, onDevicesChanged, notify }) {
|
||||
const device = devices[deviceId];
|
||||
|
||||
if (!device) {
|
||||
@@ -45,6 +46,13 @@ function DeviceDetail({ deviceId, devices, onBack }) {
|
||||
<${Controls} deviceId=${deviceId} status=${device.status} />
|
||||
<${Presets} deviceId=${deviceId} status=${device.status} />
|
||||
<${Sources} deviceId=${deviceId} status=${device.status} />
|
||||
<${StereoPair}
|
||||
deviceId=${deviceId}
|
||||
device=${device}
|
||||
devices=${devices}
|
||||
onChanged=${onDevicesChanged}
|
||||
notify=${notify}
|
||||
/>
|
||||
<${Zone} deviceId=${deviceId} devices=${devices} />
|
||||
<${Recents} deviceId=${deviceId} />
|
||||
</div>
|
||||
@@ -161,6 +169,11 @@ function App() {
|
||||
await api.discover();
|
||||
}
|
||||
|
||||
async function refreshDevices() {
|
||||
const resp = await api.devices();
|
||||
if (resp?.success) setDevices(resp.data || {});
|
||||
}
|
||||
|
||||
async function removeDevice(id) {
|
||||
const name = devices[id]?.info?.name || id;
|
||||
if (!confirm(`Remove "${name}"?\n\nThis clears it from AfterTouch. A device still online may reappear after the next discovery scan.`)) {
|
||||
@@ -262,6 +275,8 @@ function App() {
|
||||
deviceId=${selectedId}
|
||||
devices=${devices}
|
||||
onBack=${() => navigate('devices')}
|
||||
onDevicesChanged=${refreshDevices}
|
||||
notify=${showToast}
|
||||
/>
|
||||
` : page === 'tunein' ? html`
|
||||
<${TuneInBrowser} key="tunein-browser" devices=${devices} />
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import htm from 'htm';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
function isStereoCapable(device) {
|
||||
const type = (device?.info?.type || '').trim().toLowerCase();
|
||||
return type === 'st10' || type === 'soundtouch 10';
|
||||
}
|
||||
|
||||
function responseError(resp, fallback) {
|
||||
const details = (resp?.data?.members || []).flatMap(member => [
|
||||
member.preflightError,
|
||||
member.mutationError,
|
||||
member.verificationError,
|
||||
member.compensationError,
|
||||
].filter(Boolean).map(message => `${member.ipAddress || member.deviceId || 'speaker'}: ${message}`));
|
||||
|
||||
return [resp?.error || fallback, resp?.data?.persistenceError, ...details].filter(Boolean).join('; ');
|
||||
}
|
||||
|
||||
function groupId(group) {
|
||||
return group?.ID || group?.id || '';
|
||||
}
|
||||
|
||||
function groupRoles(group) {
|
||||
return group?.Roles?.Roles || group?.roles?.roles || [];
|
||||
}
|
||||
|
||||
function hasConfiguredGroup(group) {
|
||||
return Boolean(group && (groupId(group) || group?.MasterDeviceID || group?.masterDeviceId || groupRoles(group).length));
|
||||
}
|
||||
|
||||
function snapshotFromProjection(pair) {
|
||||
if (!pair?.id) return null;
|
||||
return {
|
||||
ID: pair.id,
|
||||
Name: pair.name || '',
|
||||
MasterDeviceID: pair.masterDeviceId,
|
||||
Roles: {
|
||||
Roles: (pair.members || []).map(member => ({
|
||||
DeviceID: member.deviceId,
|
||||
Role: member.role,
|
||||
IPAddress: member.ipAddress,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function StereoPair({ deviceId, device, devices, onChanged, notify }) {
|
||||
const pair = device?.stereoPair;
|
||||
const [state, setState] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const [rightId, setRightId] = useState('');
|
||||
const [name, setName] = useState(pair?.name || device?.info?.name || '');
|
||||
const [savedRecovery, setSavedRecovery] = useState(null);
|
||||
const refreshGeneration = useRef(0);
|
||||
const nameEdited = useRef(false);
|
||||
const mutationError = useRef('');
|
||||
const previousDeviceId = useRef(deviceId);
|
||||
const mounted = useRef(true);
|
||||
const currentSelection = useRef({ deviceId });
|
||||
if (currentSelection.current.deviceId !== deviceId) {
|
||||
currentSelection.current = { deviceId };
|
||||
}
|
||||
const refreshKey = JSON.stringify([deviceId, pair?.id || null, pair?.name || null]);
|
||||
const currentRefreshKey = useRef(refreshKey);
|
||||
currentRefreshKey.current = refreshKey;
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function refresh({ preserveError = false } = {}) {
|
||||
if (refreshKey !== currentRefreshKey.current) return;
|
||||
const generation = ++refreshGeneration.current;
|
||||
try {
|
||||
const resp = await api.stereoPair(deviceId);
|
||||
if (generation !== refreshGeneration.current || refreshKey !== currentRefreshKey.current) return;
|
||||
if (resp?.data) {
|
||||
setState(resp.data);
|
||||
const currentName = resp.data.group?.Name || resp.data.group?.name;
|
||||
if (currentName && !nameEdited.current) setName(currentName);
|
||||
}
|
||||
if (resp?.success) {
|
||||
if (!preserveError && !mutationError.current) setError('');
|
||||
} else if (!preserveError && !mutationError.current) {
|
||||
setError(responseError(resp, 'Unable to read stereo-pair state'));
|
||||
}
|
||||
} catch (_) {
|
||||
if (generation === refreshGeneration.current && refreshKey === currentRefreshKey.current &&
|
||||
!preserveError && !mutationError.current) {
|
||||
setError('Unable to read stereo-pair state');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (previousDeviceId.current !== deviceId) {
|
||||
previousDeviceId.current = deviceId;
|
||||
nameEdited.current = false;
|
||||
mutationError.current = '';
|
||||
setState(null);
|
||||
setError('');
|
||||
setBusy(false);
|
||||
setSavedRecovery(null);
|
||||
}
|
||||
if (!nameEdited.current) setName(pair?.name || device?.info?.name || '');
|
||||
setRightId('');
|
||||
setShowPicker(false);
|
||||
refresh();
|
||||
return () => {
|
||||
refreshGeneration.current++;
|
||||
};
|
||||
}, [deviceId, pair?.id, pair?.name]);
|
||||
|
||||
const stateGroup = state?.group;
|
||||
const stateSnapshot = hasConfiguredGroup(stateGroup) && (!pair?.id || groupId(stateGroup) === pair.id)
|
||||
? stateGroup : null;
|
||||
const observedSnapshot = stateSnapshot || snapshotFromProjection(pair);
|
||||
const savedSnapshot = savedRecovery?.deviceId === deviceId ? savedRecovery.group : null;
|
||||
const recoverySnapshot = observedSnapshot || savedSnapshot;
|
||||
const recoveryGroupRoles = groupRoles(recoverySnapshot);
|
||||
const recoveryGroupId = groupId(recoverySnapshot);
|
||||
const recoveryGroup = !pair && hasConfiguredGroup(recoverySnapshot);
|
||||
const expectedGroupId = pair?.id || recoveryGroupId;
|
||||
|
||||
const candidates = Object.entries(devices || {}).filter(([id, candidate]) =>
|
||||
id !== deviceId && !candidate.stereoPair && !(candidate.status?.group?.ID || candidate.status?.group?.id) &&
|
||||
candidate.status?.isConnected && isStereoCapable(candidate));
|
||||
|
||||
async function recoverFromFailure(operationError) {
|
||||
mutationError.current = operationError;
|
||||
setError(operationError);
|
||||
await Promise.allSettled([
|
||||
Promise.resolve().then(() => onChanged?.()),
|
||||
refresh({ preserveError: true }),
|
||||
]);
|
||||
}
|
||||
|
||||
async function run(action, successMessage, onSuccess) {
|
||||
const selection = currentSelection.current;
|
||||
const isCurrentMutation = () => mounted.current && selection === currentSelection.current;
|
||||
setBusy(true);
|
||||
mutationError.current = '';
|
||||
setError('');
|
||||
try {
|
||||
const resp = await action();
|
||||
if (!isCurrentMutation()) return;
|
||||
refreshGeneration.current++;
|
||||
if (!resp?.success) {
|
||||
await recoverFromFailure(responseError(resp, 'Stereo-pair operation failed'));
|
||||
return;
|
||||
}
|
||||
nameEdited.current = false;
|
||||
setShowPicker(false);
|
||||
onSuccess?.();
|
||||
notify?.(successMessage);
|
||||
await onChanged?.();
|
||||
await refresh();
|
||||
} catch (_) {
|
||||
if (isCurrentMutation()) {
|
||||
await recoverFromFailure('Stereo-pair operation failed');
|
||||
}
|
||||
} finally {
|
||||
if (isCurrentMutation()) setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function editName(event) {
|
||||
nameEdited.current = true;
|
||||
setName(event.currentTarget.value);
|
||||
}
|
||||
|
||||
function createPair() {
|
||||
if (!rightId) return;
|
||||
run(() => api.stereoPairCreate(deviceId, rightId, name.trim()), 'Stereo pair created');
|
||||
}
|
||||
|
||||
function renamePair(event) {
|
||||
event.preventDefault();
|
||||
const nextName = name.trim();
|
||||
if (!expectedGroupId || !nextName || nextName === pair?.name) return;
|
||||
run(() => api.stereoPairRename(deviceId, expectedGroupId, nextName), 'Stereo pair renamed');
|
||||
}
|
||||
|
||||
function dissolvePair() {
|
||||
if (!expectedGroupId || !recoverySnapshot) return;
|
||||
const pairName = pair?.name || recoverySnapshot?.Name || recoverySnapshot?.name ||
|
||||
device?.info?.name || 'this stereo pair';
|
||||
if (!confirm(`Dissolve "${pairName}"?\n\nBoth speakers will become standalone devices.`)) return;
|
||||
setSavedRecovery({ deviceId, group: recoverySnapshot });
|
||||
run(
|
||||
() => api.stereoPairDissolve(deviceId, expectedGroupId, recoverySnapshot),
|
||||
'Stereo pair dissolved',
|
||||
() => setSavedRecovery(null),
|
||||
);
|
||||
}
|
||||
|
||||
if (!pair && !recoveryGroup && state && !state.capable) return null;
|
||||
if (!pair && !state && !isStereoCapable(device)) return null;
|
||||
|
||||
return html`
|
||||
<section class="stereo-pair-section">
|
||||
<div class="section-title">Stereo pair</div>
|
||||
${error ? html`<div class="stereo-pair-error" role="alert">${error}</div>` : null}
|
||||
|
||||
${pair ? html`
|
||||
<div class="stereo-pair-members">
|
||||
${(pair.members || []).map(member => html`
|
||||
<div class="stereo-pair-member" key=${member.deviceId}>
|
||||
<span class="stereo-role">${member.role}</span>
|
||||
<span class="stereo-member-name">${member.name || member.ipAddress || member.deviceId}</span>
|
||||
<span class="device-indicator ${member.available ? 'online' : 'offline'}"
|
||||
title=${member.available ? 'Online' : 'Unavailable'}></span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
<form class="stereo-pair-actions" onSubmit=${renamePair}>
|
||||
<label class="stereo-name-field">
|
||||
<span>Name</span>
|
||||
<input value=${name} onInput=${editName}
|
||||
disabled=${busy} maxlength="64" />
|
||||
</label>
|
||||
<button class="btn-secondary stereo-action" type="submit"
|
||||
disabled=${busy || !expectedGroupId || !name.trim() || name.trim() === pair.name}>Rename</button>
|
||||
<button class="btn-secondary stereo-action danger" type="button"
|
||||
onClick=${dissolvePair} disabled=${busy || !expectedGroupId}>Dissolve</button>
|
||||
</form>
|
||||
` : recoveryGroup ? html`
|
||||
<div class="stereo-pair-members">
|
||||
<div class="stereo-pair-member">
|
||||
<span class="stereo-role">Degraded</span>
|
||||
<span class="stereo-member-name">${recoverySnapshot.Name || recoverySnapshot.name || 'Unnamed stereo pair'}</span>
|
||||
</div>
|
||||
<div class="stereo-pair-member">
|
||||
<span class="stereo-role">Generation ID</span>
|
||||
<span class="stereo-member-name">${recoveryGroupId}</span>
|
||||
</div>
|
||||
${recoveryGroupRoles.map(member => html`
|
||||
<div class="stereo-pair-member" key=${member.DeviceID || member.deviceId || member.Role || member.role}>
|
||||
<span class="stereo-role">${member.Role || member.role || 'Member'}</span>
|
||||
<span class="stereo-member-name">${member.IPAddress || member.ipAddress || member.DeviceID || member.deviceId || 'Unknown member'}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
<div class="stereo-pair-actions">
|
||||
<button class="btn-secondary stereo-action danger" type="button"
|
||||
onClick=${dissolvePair} disabled=${busy || !expectedGroupId}>Dissolve</button>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="stereo-pair-standalone">
|
||||
<span>Standalone</span>
|
||||
<button class="btn-secondary stereo-action" onClick=${() => setShowPicker(true)}
|
||||
disabled=${busy || !state?.capable || candidates.length === 0}>Create stereo pair</button>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${showPicker ? html`
|
||||
<div class="overlay" onClick=${() => setShowPicker(false)}>
|
||||
<div class="device-picker stereo-picker" onClick=${event => event.stopPropagation()}>
|
||||
<div class="picker-title">Create stereo pair</div>
|
||||
<label class="stereo-name-field picker-name-field">
|
||||
<span>Name</span>
|
||||
<input value=${name} onInput=${editName} maxlength="64" />
|
||||
</label>
|
||||
<div class="picker-label">Right speaker</div>
|
||||
<div class="picker-devices">
|
||||
${candidates.map(([id, candidate]) => html`
|
||||
<button class="picker-device-btn ${rightId === id ? 'selected' : ''}"
|
||||
type="button" key=${id} onClick=${() => setRightId(id)}>
|
||||
<div class="picker-device-info">
|
||||
<span class="picker-device-name">${candidate.info?.name || id}</span>
|
||||
<span class="picker-device-ip">${candidate.info?.ip_address || id}</span>
|
||||
</div>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<div class="stereo-picker-actions">
|
||||
<button class="btn-secondary" type="button" onClick=${() => setShowPicker(false)}>Cancel</button>
|
||||
<button class="btn-primary" type="button" onClick=${createPair}
|
||||
disabled=${busy || !rightId || !name.trim()}>Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : null}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
@@ -306,6 +306,70 @@ func (app *WebApp) applyBassEvent(
|
||||
})
|
||||
}
|
||||
|
||||
// registerDeviceWebSocketClient gives conn its own write-serialization lock,
|
||||
// mirroring registerGlobalWebSocket's role for the browser-wide pool. Unlike
|
||||
// registerGlobalWebSocket, there are no initial frames to send under it --
|
||||
// callers install the lock before their first write.
|
||||
func (app *WebApp) registerDeviceWebSocketClient(conn webSocketWriter) {
|
||||
app.DeviceWSMutex.Lock()
|
||||
app.DeviceWSClients[conn] = &sync.Mutex{}
|
||||
app.DeviceWSMutex.Unlock()
|
||||
}
|
||||
|
||||
// removeDeviceWebSocketClient unregisters conn. Unlike
|
||||
// removeGlobalWebSocketClient, callers close the underlying connection
|
||||
// themselves (HandleDeviceWebSocket already does via its own defer), so this
|
||||
// only needs to drop the registry entry.
|
||||
func (app *WebApp) removeDeviceWebSocketClient(conn webSocketWriter) {
|
||||
app.DeviceWSMutex.Lock()
|
||||
delete(app.DeviceWSClients, conn)
|
||||
app.DeviceWSMutex.Unlock()
|
||||
}
|
||||
|
||||
// withDeviceConnWrite is withConnWrite for the per-device status pool.
|
||||
func (app *WebApp) withDeviceConnWrite(conn webSocketWriter, write func(webSocketWriteBatch) error) error {
|
||||
app.DeviceWSMutex.RLock()
|
||||
mu := app.DeviceWSClients[conn]
|
||||
app.DeviceWSMutex.RUnlock()
|
||||
|
||||
if mu == nil {
|
||||
return errConnUnregistered
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
return write(webSocketWriteBatch{timeout: app.writeTimeout()})
|
||||
}
|
||||
|
||||
func (app *WebApp) deviceWebSocketClients() []webSocketWriter {
|
||||
app.DeviceWSMutex.RLock()
|
||||
defer app.DeviceWSMutex.RUnlock()
|
||||
|
||||
clients := make([]webSocketWriter, 0, len(app.DeviceWSClients))
|
||||
for client := range app.DeviceWSClients {
|
||||
clients = append(clients, client)
|
||||
}
|
||||
|
||||
return clients
|
||||
}
|
||||
|
||||
// awaitPriorGlobalWebSocketWrites is an ordering barrier across both browser
|
||||
// WebSocket connection pools (the global device-list feed and per-device
|
||||
// status feeds). Once it returns, any write already in flight on any
|
||||
// currently-registered connection in either pool has completed, so a caller
|
||||
// that just applied a fresh projection is guaranteed a later write captures
|
||||
// it rather than racing a stale one still being sent.
|
||||
func (app *WebApp) awaitPriorGlobalWebSocketWrites() {
|
||||
for _, client := range app.globalWebSocketClients() {
|
||||
_ = app.withConnWrite(client, func(webSocketWriteBatch) error { return nil })
|
||||
}
|
||||
|
||||
for _, client := range app.deviceWebSocketClients() {
|
||||
_ = app.withDeviceConnWrite(client, func(webSocketWriteBatch) error { return nil })
|
||||
}
|
||||
}
|
||||
|
||||
// HandleWebSocket handles WebSocket connections for real-time updates
|
||||
func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := app.Upgrader.Upgrade(w, r, nil)
|
||||
@@ -703,19 +767,22 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
app.registerDeviceWebSocketClient(conn)
|
||||
defer app.removeDeviceWebSocketClient(conn)
|
||||
|
||||
log.Printf("Device WebSocket connected for %s", sanitizeLog(deviceID))
|
||||
|
||||
// Send initial device status
|
||||
initialMessage := webtypes.WebSocketMessage{
|
||||
Type: "device_status",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status(),
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(initialMessage); err != nil {
|
||||
// Capture and send under the same ordering seam used by lifecycle responses.
|
||||
if err := app.withDeviceConnWrite(conn, func(batch webSocketWriteBatch) error {
|
||||
return batch.writeJSON(conn, 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
|
||||
}
|
||||
@@ -746,45 +813,50 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
// Send ping to check if client is still connected
|
||||
if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
|
||||
log.Printf("Failed to send ping to device WebSocket %s: %v", sanitizeLog(deviceID), err)
|
||||
if err := app.writeDeviceWebSocketUpdate(conn, deviceID, device); err != nil {
|
||||
log.Printf("Failed to send device WebSocket update for %s: %v", sanitizeLog(deviceID), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send device status update
|
||||
func (app *WebApp) writeDeviceWebSocketUpdate(
|
||||
conn webSocketWriter,
|
||||
deviceID string,
|
||||
device *webtypes.DeviceConnection,
|
||||
) error {
|
||||
return app.withDeviceConnWrite(conn, func(batch webSocketWriteBatch) error {
|
||||
// Capture after taking the lifecycle ordering lock. A status frame
|
||||
// captured before a pair mutation therefore cannot follow its response.
|
||||
status := device.Status()
|
||||
statusMessage := webtypes.WebSocketMessage{
|
||||
|
||||
if err := batch.writeMessage(conn, websocket.PingMessage, []byte{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := batch.writeJSON(conn, webtypes.WebSocketMessage{
|
||||
Type: "device_status",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": status,
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(statusMessage); err != nil {
|
||||
log.Printf("Failed to send device status update for %s: %v", sanitizeLog(deviceID), err)
|
||||
return
|
||||
if device.WebSocket == nil || !status.IsConnected {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If device has active WebSocket connection to SoundTouch device,
|
||||
// also send any real-time updates from that connection
|
||||
if device.WebSocket != nil && status.IsConnected {
|
||||
realtimeMessage := webtypes.WebSocketMessage{
|
||||
Type: "device_realtime",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
"nowPlaying": status.NowPlaying,
|
||||
"volume": status.Volume,
|
||||
"timestamp": time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(realtimeMessage); err != nil {
|
||||
log.Printf("Failed to send realtime update for %s: %v", sanitizeLog(deviceID), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return batch.writeJSON(conn, webtypes.WebSocketMessage{
|
||||
Type: "device_realtime",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
"nowPlaying": status.NowPlaying,
|
||||
"volume": status.Volume,
|
||||
"timestamp": time.Now(),
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -217,6 +217,61 @@ func TestPeriodicPlayerMessagesPreserveStatusUpdateStream(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeviceWebSocketUpdateCapturesStatusAfterLifecycleOrderingBarrier proves
|
||||
// writeDeviceWebSocketUpdate captures status only after taking this specific
|
||||
// connection's own write lock (registerDeviceWebSocketClient), not before --
|
||||
// the same "capture after taking the lock" guarantee awaitPriorGlobalWebSocketWrites
|
||||
// relies on for the ordering barrier in completeStereoPairMutation. Holding
|
||||
// the lock from before the update goroutine starts, across the mutation,
|
||||
// makes this deterministic via happens-before rather than timing: the update
|
||||
// can only proceed once we release the lock, by which point ApplyGroupEvent
|
||||
// has already run in this (the only other) goroutine.
|
||||
func TestDeviceWebSocketUpdateCapturesStatusAfterLifecycleOrderingBarrier(t *testing.T) {
|
||||
app := NewWebApp()
|
||||
device := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{DeviceID: "left-id"})
|
||||
device.SetStatus(&webtypes.DeviceStatus{Group: &models.Group{ID: "pair-old"}})
|
||||
|
||||
recorder := &recordingWebSocketWriter{}
|
||||
app.registerDeviceWebSocketClient(recorder)
|
||||
defer app.removeDeviceWebSocketClient(recorder)
|
||||
|
||||
app.DeviceWSMutex.RLock()
|
||||
connMu := app.DeviceWSClients[recorder]
|
||||
app.DeviceWSMutex.RUnlock()
|
||||
|
||||
connMu.Lock()
|
||||
|
||||
updateDone := make(chan error, 1)
|
||||
go func() {
|
||||
updateDone <- app.writeDeviceWebSocketUpdate(recorder, "left-id", device)
|
||||
}()
|
||||
|
||||
device.ApplyGroupEvent(&models.Group{ID: "pair-new"}, time.Now())
|
||||
connMu.Unlock()
|
||||
|
||||
select {
|
||||
case err := <-updateDone:
|
||||
if err != nil {
|
||||
t.Fatalf("writeDeviceWebSocketUpdate: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("device WebSocket update remained blocked")
|
||||
}
|
||||
|
||||
message, ok := recorder.firstMessage()
|
||||
if !ok || message.Type != "device_status" {
|
||||
t.Fatalf("first device message = %#v", message)
|
||||
}
|
||||
data, ok := message.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("device message data = %#v", message.Data)
|
||||
}
|
||||
status, ok := data["status"].(*webtypes.DeviceStatus)
|
||||
if !ok || status.Group == nil || status.Group.ID != "pair-new" {
|
||||
t.Fatalf("device frame captured stale group: %#v", data["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func newStatusTestServer(t *testing.T, groupStatus int, groupBody string) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
@@ -298,6 +353,18 @@ func (writer *recordingWebSocketWriter) messageSnapshot() []interface{} {
|
||||
return append([]interface{}(nil), writer.messages...)
|
||||
}
|
||||
|
||||
func (writer *recordingWebSocketWriter) firstMessage() (webtypes.WebSocketMessage, bool) {
|
||||
writer.mu.Lock()
|
||||
defer writer.mu.Unlock()
|
||||
if len(writer.messages) == 0 {
|
||||
return webtypes.WebSocketMessage{}, false
|
||||
}
|
||||
|
||||
message, ok := writer.messages[0].(webtypes.WebSocketMessage)
|
||||
|
||||
return message, ok
|
||||
}
|
||||
|
||||
type deadlineBlockingWebSocketWriter struct {
|
||||
mu sync.Mutex
|
||||
deadline time.Time
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,301 @@
|
||||
package stereopair
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// DeleteMargeGroupGeneration removes one exact group generation from the
|
||||
// backend configured by the freshly read speaker /info response.
|
||||
func DeleteMargeGroupGeneration(httpClient *http.Client, ref GenerationRef) error {
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
|
||||
current, err := getMargeDeviceGroup(httpClient, ref)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify Marge group generation before deletion: %w", err)
|
||||
}
|
||||
|
||||
if current.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if ref.ExpectedGroup == nil || !sameGroupConfiguration(current, ref.ExpectedGroup) ||
|
||||
current.ID != ref.GroupID || current.MasterDeviceID != ref.DeviceID ||
|
||||
!groupContainsDevice(current, ref.DeviceID) {
|
||||
return fmt.Errorf("delete Marge group generation: device is associated with unrelated generation or topology %q",
|
||||
current.ID)
|
||||
}
|
||||
|
||||
endpoint, err := MargeGroupGenerationURL(ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodDelete, endpoint, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create Marge generation cleanup request: %w", err)
|
||||
}
|
||||
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete Marge group generation: %w", err)
|
||||
}
|
||||
|
||||
if response.StatusCode == http.StatusNotFound ||
|
||||
(response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices) {
|
||||
_, copyErr := io.Copy(io.Discard, response.Body)
|
||||
closeErr := response.Body.Close()
|
||||
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("read Marge generation cleanup response: %w", copyErr)
|
||||
}
|
||||
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close Marge generation cleanup response: %w", closeErr)
|
||||
}
|
||||
|
||||
group, verifyErr := getMargeDeviceGroup(httpClient, ref)
|
||||
if verifyErr != nil {
|
||||
return fmt.Errorf("verify Marge group generation deletion: %w", verifyErr)
|
||||
}
|
||||
|
||||
if group.IsEmpty() || group.ID != ref.GroupID {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("delete Marge group generation: generation %s is still active", ref.GroupID)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, 1024))
|
||||
_ = response.Body.Close()
|
||||
|
||||
return fmt.Errorf("delete Marge group generation: HTTP %d: %s",
|
||||
response.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
// RenameMargeGroupGeneration updates and verifies the name of one persisted
|
||||
// generation at the backend configured by fresh speaker info. Topology, rather
|
||||
// than the old name, is the retry guard so a degraded rename can converge.
|
||||
func RenameMargeGroupGeneration(httpClient *http.Client, ref GenerationRef, name string) error {
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
|
||||
name = strings.TrimSpace(name)
|
||||
|
||||
if name == "" {
|
||||
return fmt.Errorf("%w: persisted group name must not be empty", ErrInvalidRequest)
|
||||
}
|
||||
|
||||
current, err := getMargeDeviceGroup(httpClient, ref)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify Marge group generation before rename: %w", err)
|
||||
}
|
||||
|
||||
if current.IsEmpty() || ref.ExpectedGroup == nil || current.ID != ref.GroupID ||
|
||||
current.MasterDeviceID != ref.DeviceID || !groupContainsDevice(current, ref.DeviceID) ||
|
||||
!sameGroupTopology(current, ref.ExpectedGroup) {
|
||||
return fmt.Errorf("%w: rename Marge group generation: device is associated with unrelated generation or topology %q",
|
||||
ErrConflict, current.ID)
|
||||
}
|
||||
|
||||
if current.Name == name {
|
||||
return nil
|
||||
}
|
||||
|
||||
updated := cloneGroup(current)
|
||||
updated.Name = name
|
||||
updated.Status = ""
|
||||
updated.SenderIPAddress = ""
|
||||
|
||||
body, err := xml.Marshal(updated)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode Marge group rename: %w", err)
|
||||
}
|
||||
|
||||
mutationErr, err := postMargeGroupRename(httpClient, ref, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
verified, verifyErr := getMargeDeviceGroup(httpClient, ref)
|
||||
if verifyErr == nil && verified.Name == name && sameGroupTopology(verified, updated) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if mutationErr != nil {
|
||||
return fmt.Errorf("rename Marge group generation: %w", mutationErr)
|
||||
}
|
||||
|
||||
if verifyErr != nil {
|
||||
return fmt.Errorf("verify Marge group generation rename: %w", verifyErr)
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w: rename Marge group generation: generation %s did not retain name %q",
|
||||
ErrConflict, ref.GroupID, name)
|
||||
}
|
||||
|
||||
func postMargeGroupRename(httpClient *http.Client, ref GenerationRef, body []byte) (error, error) {
|
||||
endpoint, err := MargeGroupGenerationURL(ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, endpoint, strings.NewReader(xml.Header+string(body)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create Marge group rename request: %w", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
|
||||
response, requestErr := httpClient.Do(request)
|
||||
if requestErr != nil {
|
||||
return requestErr, nil
|
||||
}
|
||||
|
||||
return margeGroupRenameResponseError(response), nil
|
||||
}
|
||||
|
||||
func margeGroupRenameResponseError(response *http.Response) error {
|
||||
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, 1024))
|
||||
|
||||
closeErr := response.Body.Close()
|
||||
|
||||
switch {
|
||||
case response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices:
|
||||
return fmt.Errorf("HTTP %d: %s", response.StatusCode, strings.TrimSpace(string(responseBody)))
|
||||
case readErr != nil:
|
||||
return fmt.Errorf("read response: %w", readErr)
|
||||
case closeErr != nil:
|
||||
return fmt.Errorf("close response: %w", closeErr)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MargeGroupGenerationURL returns the standard generation-aware group endpoint
|
||||
// below a speaker's configured Marge base URL.
|
||||
func MargeGroupGenerationURL(ref GenerationRef) (string, error) {
|
||||
if !safeMargePathSegment(ref.AccountID) || !safeMargePathSegment(ref.GroupID) {
|
||||
return "", errors.New("speaker info has no safe Marge account or group ID")
|
||||
}
|
||||
|
||||
return margeStreamingURL(ref.MargeURL, "account", ref.AccountID, "group", ref.GroupID)
|
||||
}
|
||||
|
||||
// EnsureMargeNoGroupGenerations checks that the backend has no persisted group
|
||||
// for speakers already proven physically standalone by the coordinator. The
|
||||
// check is deliberately read-only so it cannot retire a concurrently created
|
||||
// physical generation.
|
||||
func EnsureMargeNoGroupGenerations(httpClient *http.Client, refs []GenerationRef) error {
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
|
||||
for i := range refs {
|
||||
ref := refs[i]
|
||||
|
||||
group, err := getMargeDeviceGroup(httpClient, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if group.IsEmpty() {
|
||||
continue
|
||||
}
|
||||
|
||||
if !safeMargePathSegment(group.ID) || !groupContainsDevice(group, ref.DeviceID) {
|
||||
return errors.New("marge returned an unsafe or unrelated stale group generation")
|
||||
}
|
||||
|
||||
return fmt.Errorf("persisted group generation %s still contains standalone device %s",
|
||||
group.ID, ref.DeviceID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getMargeDeviceGroup(httpClient *http.Client, ref GenerationRef) (*models.Group, error) {
|
||||
if !safeMargePathSegment(ref.AccountID) || !safeMargePathSegment(ref.DeviceID) {
|
||||
return nil, errors.New("speaker info has no safe Marge account or device ID")
|
||||
}
|
||||
|
||||
endpoint, err := margeStreamingURL(ref.MargeURL, "account", ref.AccountID, "device", ref.DeviceID, "group")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create Marge group query: %w", err)
|
||||
}
|
||||
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query Marge group generation: %w", err)
|
||||
}
|
||||
|
||||
body, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||
closeErr := response.Body.Close()
|
||||
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("read Marge group generation: %w", readErr)
|
||||
}
|
||||
|
||||
if closeErr != nil {
|
||||
return nil, fmt.Errorf("close Marge group response: %w", closeErr)
|
||||
}
|
||||
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("query Marge group generation: HTTP %d: %s",
|
||||
response.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var group models.Group
|
||||
if err := xml.Unmarshal(body, &group); err != nil {
|
||||
return nil, fmt.Errorf("decode Marge group generation: %w", err)
|
||||
}
|
||||
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
func margeStreamingURL(margeURL string, segments ...string) (string, error) {
|
||||
for _, segment := range segments {
|
||||
if !safeMargePathSegment(segment) {
|
||||
return "", errors.New("unsafe Marge URL path segment")
|
||||
}
|
||||
}
|
||||
|
||||
endpoint, err := url.Parse(strings.TrimSpace(margeURL))
|
||||
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
|
||||
return "", fmt.Errorf("speaker info has no usable Marge URL %q", margeURL)
|
||||
}
|
||||
|
||||
basePath := strings.TrimRight(endpoint.Path, "/")
|
||||
if !strings.HasSuffix(basePath, "/streaming") {
|
||||
basePath = path.Join(basePath, "streaming")
|
||||
}
|
||||
|
||||
allSegments := append([]string{basePath}, segments...)
|
||||
endpoint.Path = path.Join(allSegments...)
|
||||
endpoint.RawPath = ""
|
||||
endpoint.RawQuery = ""
|
||||
endpoint.Fragment = ""
|
||||
|
||||
return endpoint.String(), nil
|
||||
}
|
||||
|
||||
func safeMargePathSegment(value string) bool {
|
||||
return value != "" && value == strings.TrimSpace(value) && value != "." && value != ".." &&
|
||||
!strings.ContainsAny(value, "/?#\\\x00\r\n")
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package stereopair
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func margeTestGroup(id string) *models.Group {
|
||||
return &models.Group{
|
||||
ID: id,
|
||||
MasterDeviceID: "LEFT-ID",
|
||||
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 TestEnsureMargeNoGroupGenerationsFailsClosedOnDiscoveredGeneration(t *testing.T) {
|
||||
getCalls := 0
|
||||
deleteCalls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/device/LEFT-ID/group"):
|
||||
getCalls++
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<group id="STALE1"><masterDeviceId>LEFT-ID</masterDeviceId><name>Old Pair</name><roles><groupRole><deviceId>LEFT-ID</deviceId><role>LEFT</role></groupRole><groupRole><deviceId>RIGHT-ID</deviceId><role>RIGHT</role></groupRole></roles></group>`))
|
||||
case r.Method == http.MethodDelete && strings.HasSuffix(r.URL.Path, "/group/STALE1"):
|
||||
deleteCalls++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := EnsureMargeNoGroupGenerations(server.Client(), []GenerationRef{{
|
||||
DeviceID: "LEFT-ID", AccountID: "ACCOUNT1", MargeURL: server.URL + "/marge",
|
||||
}})
|
||||
if err == nil || !strings.Contains(err.Error(), "STALE1") {
|
||||
t.Fatalf("error = %v, want stale generation rejection", err)
|
||||
}
|
||||
if getCalls != 1 || deleteCalls != 0 {
|
||||
t.Fatalf("requests GET=%d DELETE=%d, want 1/0", getCalls, deleteCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureMargeNoGroupGenerationsAcceptsEmptyGroup(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`<group/>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := EnsureMargeNoGroupGenerations(server.Client(), []GenerationRef{{
|
||||
DeviceID: "LEFT-ID", AccountID: "ACCOUNT1", MargeURL: server.URL,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureMargeNoGroupGenerations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureMargeNoGroupGenerationsRejectsUnrelatedGroup(t *testing.T) {
|
||||
deleteCalls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodDelete {
|
||||
deleteCalls++
|
||||
}
|
||||
_, _ = w.Write([]byte(`<group id="OTHER"><masterDeviceId>OTHER-LEFT</masterDeviceId><roles><groupRole><deviceId>OTHER-LEFT</deviceId><role>LEFT</role></groupRole><groupRole><deviceId>OTHER-RIGHT</deviceId><role>RIGHT</role></groupRole></roles></group>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := EnsureMargeNoGroupGenerations(server.Client(), []GenerationRef{{
|
||||
DeviceID: "LEFT-ID", AccountID: "ACCOUNT1", MargeURL: server.URL,
|
||||
}})
|
||||
if err == nil || !strings.Contains(err.Error(), "unrelated") {
|
||||
t.Fatalf("error = %v, want unrelated-group rejection", err)
|
||||
}
|
||||
if deleteCalls != 0 {
|
||||
t.Fatalf("unsafe DELETE calls = %d, want 0", deleteCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureMargeNoGroupGenerationsRejectsMissingEndpoint(t *testing.T) {
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
|
||||
err := EnsureMargeNoGroupGenerations(server.Client(), []GenerationRef{{
|
||||
DeviceID: "LEFT-ID", AccountID: "ACCOUNT1", MargeURL: server.URL,
|
||||
}})
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 404") {
|
||||
t.Fatalf("error = %v, want fail-closed HTTP 404", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMargeGroupGenerationDoesNotHideConflict(t *testing.T) {
|
||||
deleteCalls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodDelete {
|
||||
deleteCalls++
|
||||
http.Error(w, "active generation conflicts with tombstone", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write([]byte(`<group id="PAIR1"><masterDeviceId>LEFT-ID</masterDeviceId><roles><groupRole><deviceId>LEFT-ID</deviceId><role>LEFT</role><ipAddress>192.0.2.10</ipAddress></groupRole><groupRole><deviceId>RIGHT-ID</deviceId><role>RIGHT</role><ipAddress>192.0.2.11</ipAddress></groupRole></roles></group>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := DeleteMargeGroupGeneration(server.Client(), GenerationRef{
|
||||
MargeURL: server.URL, AccountID: "ACCOUNT1", GroupID: "PAIR1", DeviceID: "LEFT-ID",
|
||||
ExpectedGroup: margeTestGroup("PAIR1"),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 409") {
|
||||
t.Fatalf("error = %v, want propagated HTTP 409", err)
|
||||
}
|
||||
if deleteCalls != 1 {
|
||||
t.Fatalf("DELETE calls = %d, want 1", deleteCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMargeGroupGenerationVerifiesExactGenerationIsGone(t *testing.T) {
|
||||
deleteCalls := 0
|
||||
getCalls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodDelete:
|
||||
deleteCalls++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case http.MethodGet:
|
||||
getCalls++
|
||||
_, _ = w.Write([]byte(`<group id="PAIR1"><masterDeviceId>LEFT-ID</masterDeviceId><roles><groupRole><deviceId>LEFT-ID</deviceId><role>LEFT</role><ipAddress>192.0.2.10</ipAddress></groupRole><groupRole><deviceId>RIGHT-ID</deviceId><role>RIGHT</role><ipAddress>192.0.2.11</ipAddress></groupRole></roles></group>`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := DeleteMargeGroupGeneration(server.Client(), GenerationRef{
|
||||
MargeURL: server.URL, AccountID: "ACCOUNT1", GroupID: "PAIR1", DeviceID: "LEFT-ID",
|
||||
ExpectedGroup: margeTestGroup("PAIR1"),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "still active") {
|
||||
t.Fatalf("error = %v, want failed postcondition", err)
|
||||
}
|
||||
if deleteCalls != 1 || getCalls != 2 {
|
||||
t.Fatalf("requests DELETE=%d GET=%d, want 1/2", deleteCalls, getCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMargeGroupGenerationAcceptsVerifiedAbsenceAfter404(t *testing.T) {
|
||||
deleteSeen := false
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodDelete {
|
||||
deleteSeen = true
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if deleteSeen {
|
||||
_, _ = w.Write([]byte(`<group/>`))
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write([]byte(`<group id="PAIR1"><masterDeviceId>LEFT-ID</masterDeviceId><roles><groupRole><deviceId>LEFT-ID</deviceId><role>LEFT</role><ipAddress>192.0.2.10</ipAddress></groupRole><groupRole><deviceId>RIGHT-ID</deviceId><role>RIGHT</role><ipAddress>192.0.2.11</ipAddress></groupRole></roles></group>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := DeleteMargeGroupGeneration(server.Client(), GenerationRef{
|
||||
MargeURL: server.URL, AccountID: "ACCOUNT1", GroupID: "PAIR1", DeviceID: "LEFT-ID",
|
||||
ExpectedGroup: margeTestGroup("PAIR1"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteMargeGroupGeneration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMargeGroupGenerationRejectsUnrelatedGenerationBeforeDelete(t *testing.T) {
|
||||
deleteCalls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodDelete {
|
||||
deleteCalls++
|
||||
}
|
||||
_, _ = w.Write([]byte(`<group id="OTHER"><masterDeviceId>LEFT-ID</masterDeviceId><roles><groupRole><deviceId>LEFT-ID</deviceId><role>LEFT</role></groupRole><groupRole><deviceId>RIGHT-ID</deviceId><role>RIGHT</role></groupRole></roles></group>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := DeleteMargeGroupGeneration(server.Client(), GenerationRef{
|
||||
MargeURL: server.URL, AccountID: "ACCOUNT1", GroupID: "PAIR1", DeviceID: "LEFT-ID",
|
||||
ExpectedGroup: margeTestGroup("PAIR1"),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "unrelated generation") {
|
||||
t.Fatalf("error = %v, want unrelated-generation rejection", err)
|
||||
}
|
||||
if deleteCalls != 0 {
|
||||
t.Fatalf("DELETE calls = %d, want 0", deleteCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMargeGroupGenerationRejectsSubstitutedMemberBeforeDelete(t *testing.T) {
|
||||
deleteCalls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodDelete {
|
||||
deleteCalls++
|
||||
}
|
||||
_, _ = w.Write([]byte(`<group id="PAIR1"><masterDeviceId>LEFT-ID</masterDeviceId><roles><groupRole><deviceId>LEFT-ID</deviceId><role>LEFT</role><ipAddress>192.0.2.10</ipAddress></groupRole><groupRole><deviceId>REAL-RIGHT-ID</deviceId><role>RIGHT</role><ipAddress>192.0.2.11</ipAddress></groupRole></roles></group>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
submitted := margeTestGroup("PAIR1")
|
||||
submitted.Roles.Roles[1].DeviceID = "SUBSTITUTE-RIGHT-ID"
|
||||
err := DeleteMargeGroupGeneration(server.Client(), GenerationRef{
|
||||
MargeURL: server.URL, AccountID: "ACCOUNT1", GroupID: "PAIR1", DeviceID: "LEFT-ID",
|
||||
ExpectedGroup: submitted,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "topology") {
|
||||
t.Fatalf("error = %v, want topology rejection", err)
|
||||
}
|
||||
if deleteCalls != 0 {
|
||||
t.Fatalf("DELETE calls = %d, want 0", deleteCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMargeGroupGenerationTreatsVerifiedAbsenceAsIdempotent(t *testing.T) {
|
||||
deleteCalls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodDelete {
|
||||
deleteCalls++
|
||||
}
|
||||
_, _ = w.Write([]byte(`<group/>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := DeleteMargeGroupGeneration(server.Client(), GenerationRef{
|
||||
MargeURL: server.URL, AccountID: "ACCOUNT1", GroupID: "PAIR1", DeviceID: "LEFT-ID",
|
||||
ExpectedGroup: margeTestGroup("PAIR1"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteMargeGroupGeneration: %v", err)
|
||||
}
|
||||
if deleteCalls != 0 {
|
||||
t.Fatalf("DELETE calls = %d, want 0", deleteCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameMargeGroupGenerationUpdatesAndVerifiesExactGeneration(t *testing.T) {
|
||||
current := margeTestGroup("PAIR1")
|
||||
current.Name = "Old name"
|
||||
postCalls := 0
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
data, _ := xml.Marshal(current)
|
||||
_, _ = w.Write(data)
|
||||
case http.MethodPost:
|
||||
postCalls++
|
||||
var update models.Group
|
||||
if err := xml.NewDecoder(r.Body).Decode(&update); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
current = &update
|
||||
data, _ := xml.Marshal(current)
|
||||
_, _ = w.Write(data)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ref := GenerationRef{
|
||||
MargeURL: server.URL + "/marge", AccountID: "ACCOUNT1", GroupID: "PAIR1", DeviceID: "LEFT-ID",
|
||||
ExpectedGroup: margeTestGroup("PAIR1"),
|
||||
}
|
||||
if err := RenameMargeGroupGeneration(server.Client(), ref, "New name"); err != nil {
|
||||
t.Fatalf("RenameMargeGroupGeneration: %v", err)
|
||||
}
|
||||
if current.Name != "New name" || postCalls != 1 {
|
||||
t.Fatalf("current name = %q, POST calls = %d; want New name, 1", current.Name, postCalls)
|
||||
}
|
||||
if err := RenameMargeGroupGeneration(server.Client(), ref, "New name"); err != nil {
|
||||
t.Fatalf("idempotent RenameMargeGroupGeneration: %v", err)
|
||||
}
|
||||
if postCalls != 1 {
|
||||
t.Fatalf("idempotent retry POST calls = %d, want 1", postCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameMargeGroupGenerationAcceptsVerifiedStateAfterErrorResponse(t *testing.T) {
|
||||
current := margeTestGroup("PAIR1")
|
||||
current.Name = "Old name"
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
data, _ := xml.Marshal(current)
|
||||
_, _ = w.Write(data)
|
||||
case http.MethodPost:
|
||||
var update models.Group
|
||||
if err := xml.NewDecoder(r.Body).Decode(&update); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
current = &update
|
||||
http.Error(w, "response lost after commit", http.StatusInternalServerError)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := RenameMargeGroupGeneration(server.Client(), GenerationRef{
|
||||
MargeURL: server.URL, AccountID: "ACCOUNT1", GroupID: "PAIR1", DeviceID: "LEFT-ID",
|
||||
ExpectedGroup: margeTestGroup("PAIR1"),
|
||||
}, "New name")
|
||||
if err != nil {
|
||||
t.Fatalf("verified state after error response: %v", err)
|
||||
}
|
||||
if current.Name != "New name" {
|
||||
t.Fatalf("current name = %q, want New name", current.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameMargeGroupGenerationRejectsUnrelatedTopology(t *testing.T) {
|
||||
postCalls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
postCalls++
|
||||
}
|
||||
_, _ = w.Write([]byte(`<group id="PAIR1"><name>Old</name><masterDeviceId>LEFT-ID</masterDeviceId><roles><groupRole><deviceId>LEFT-ID</deviceId><role>LEFT</role><ipAddress>192.0.2.10</ipAddress></groupRole><groupRole><deviceId>OTHER-RIGHT</deviceId><role>RIGHT</role><ipAddress>192.0.2.11</ipAddress></groupRole></roles></group>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := RenameMargeGroupGeneration(server.Client(), GenerationRef{
|
||||
MargeURL: server.URL, AccountID: "ACCOUNT1", GroupID: "PAIR1", DeviceID: "LEFT-ID",
|
||||
ExpectedGroup: margeTestGroup("PAIR1"),
|
||||
}, "New name")
|
||||
if err == nil || !strings.Contains(err.Error(), "unrelated") {
|
||||
t.Fatalf("error = %v, want unrelated-topology rejection", err)
|
||||
}
|
||||
if postCalls != 0 {
|
||||
t.Fatalf("POST calls = %d, want 0", postCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeGroupGenerationURLRejectsDotSegments(t *testing.T) {
|
||||
for _, ref := range []GenerationRef{
|
||||
{MargeURL: "http://example.test", AccountID: "..", GroupID: "PAIR1"},
|
||||
{MargeURL: "http://example.test", AccountID: "ACCOUNT1", GroupID: "."},
|
||||
} {
|
||||
if endpoint, err := MargeGroupGenerationURL(ref); err == nil {
|
||||
t.Fatalf("MargeGroupGenerationURL(%+v) = %q, want error", ref, endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameMargeBackendNormalizesDefaultPorts(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
left string
|
||||
right string
|
||||
same bool
|
||||
}{
|
||||
{name: "http default", left: "http://aftertouch.test", right: "http://aftertouch.test:80/streaming", same: true},
|
||||
{name: "https default", left: "https://aftertouch.test/streaming", right: "https://aftertouch.test:443", same: true},
|
||||
{name: "non-default port", left: "https://aftertouch.test", right: "https://aftertouch.test:18443", same: false},
|
||||
{name: "different prefix", left: "http://aftertouch.test/marge", right: "http://aftertouch.test", same: false},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := SameMargeBackend(test.left, test.right); got != test.same {
|
||||
t.Fatalf("SameMargeBackend(%q, %q) = %v, want %v", test.left, test.right, got, test.same)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,9 @@ Accept: application/vnd.bose.streaming-v1.2+xml
|
||||
|
||||
### DELETE /streaming/account/{accountId}/group/ (account-level teardown, no id)
|
||||
###
|
||||
### The no-id, trailing-slash form a speaker sends to clear all of an account's
|
||||
### groups (e.g. during a factory reset). HandleMargeDeleteAccountGroups -> 200.
|
||||
### The no-id, trailing-slash form is emitted by legacy speakers but cannot
|
||||
### identify a group generation safely. The server acknowledges it without
|
||||
### deleting persistent state; generation-aware callers use /group/{groupId}.
|
||||
DELETE {{host}}/streaming/account/{{accountId}}/group/
|
||||
Content-Type: application/vnd.bose.streaming-v1.2+xml
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
Reference in New Issue
Block a user