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() } }