mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
Two bugs prevented clean stereo-pair teardown: 1. removeGroup (CLI) only contacted the --host speaker (master). The slave never received /removeGroup and stayed stuck in GroupSlave state indefinitely, blocking direct playback. Fix: fetch the current group first, then send /removeGroup to every member in parallel — mirrors the same symmetry as createGroup (issue #252). 2. Speakers send DELETE /streaming/account/{id}/group/ (trailing slash, no group ID) during teardown. Master and slave live in different accounts, so each deletes its own copy independently. AfterTouch had no route for this form → 405. Fix: add DeleteAllGroupsForAccount to the datastore (scans Group_*.xml, idempotent if none found) and wire DELETE /group and DELETE /group/ to a new HandleMargeDeleteAccountGroups handler in both routing blocks. Confirmed: after the fix both DELETE calls return 200 and the slave exits GroupSlave state cleanly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
384 lines
10 KiB
Go
384 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"sync"
|
|
|
|
"github.com/gesellix/bose-soundtouch/pkg/client"
|
|
"github.com/gesellix/bose-soundtouch/pkg/models"
|
|
"github.com/gesellix/bose-soundtouch/pkg/speaker"
|
|
"github.com/urfave/cli/v2"
|
|
)
|
|
|
|
// getGroupStatus retrieves and prints the device's current stereo-pair state.
|
|
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()
|
|
if err != nil {
|
|
PrintError(fmt.Sprintf("Failed to get group: %v", err))
|
|
return err
|
|
}
|
|
|
|
if group.IsEmpty() {
|
|
fmt.Println("Device is not in a stereo pair")
|
|
return nil
|
|
}
|
|
|
|
printGroup(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.
|
|
func createGroup(c *cli.Context) error {
|
|
leftIP := c.String("left")
|
|
rightIP := c.String("right")
|
|
name := c.String("name")
|
|
|
|
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)
|
|
|
|
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{
|
|
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))
|
|
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)
|
|
|
|
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.
|
|
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)
|
|
if err != nil {
|
|
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
|
return err
|
|
}
|
|
|
|
current, err := stClient.GetGroup()
|
|
if err != nil {
|
|
PrintError(fmt.Sprintf("Failed to read current group: %v", err))
|
|
return err
|
|
}
|
|
|
|
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)
|
|
if err != nil {
|
|
PrintError(fmt.Sprintf("Failed to rename group: %v", err))
|
|
return err
|
|
}
|
|
|
|
PrintSuccess(fmt.Sprintf("Stereo pair renamed to %q", result.Name))
|
|
printGroup(result)
|
|
|
|
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).
|
|
func removeGroup(c *cli.Context) error {
|
|
clientConfig := GetClientConfig(c)
|
|
PrintDeviceHeader("Removing stereo pair", clientConfig.Host, clientConfig.Port)
|
|
|
|
stClient, err := CreateSoundTouchClient(clientConfig)
|
|
if err != nil {
|
|
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
|
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() {
|
|
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
|
|
}
|
|
|
|
members := make([]string, 0, len(group.Roles.Roles))
|
|
seen := map[string]bool{}
|
|
|
|
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")
|
|
}
|
|
|
|
PrintSuccess("Stereo pair removed")
|
|
|
|
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
|
|
}
|
|
|
|
return stClient.GetDeviceInfo()
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
return client.NewClient(&client.Config{
|
|
Host: host,
|
|
Port: speaker.HTTPPort,
|
|
Timeout: cfg.HTTPTimeout,
|
|
UserAgent: cfg.UserAgent,
|
|
}), nil
|
|
}
|
|
|
|
func printGroup(g *models.Group) {
|
|
fmt.Println("Stereo Pair Configuration:")
|
|
fmt.Printf(" ID: %s\n", g.ID)
|
|
fmt.Printf(" Name: %s\n", g.Name)
|
|
fmt.Printf(" Master: %s\n", g.MasterDeviceID)
|
|
|
|
if g.Status != "" {
|
|
fmt.Printf(" Status: %s\n", g.Status)
|
|
}
|
|
|
|
for _, r := range g.Roles.Roles {
|
|
fmt.Printf(" %-5s %s", r.Role, r.DeviceID)
|
|
|
|
if r.IPAddress != "" {
|
|
fmt.Printf(" (IP: %s)", r.IPAddress)
|
|
}
|
|
|
|
fmt.Println()
|
|
}
|
|
}
|