feat: add group CRUD endpoints (POST add, POST modify, DELETE delete) (#191)

Groups (stereo pairs of ST10 speakers) were read-only — the GET endpoint
always returned an empty <group/>. Add POST /account/{account}/group,
POST /account/{account}/group/{groupId}, and DELETE
/account/{account}/group/{groupId} with datastore persistence, matching
the API shape observed in soundcork. The GET endpoint now reads live
group state from the datastore.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-04-28 15:28:38 +02:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent ff6edc5383
commit 9412b5ffa0
9 changed files with 510 additions and 6 deletions
+5
View File
@@ -0,0 +1,5 @@
# Files intentionally not linked in docs/SUMMARY.md.
# Paths are relative to the docs/ directory.
# Lines starting with # and blank lines are ignored.
analysis/bose-soundtouch-community-tools.md
+8
View File
@@ -804,6 +804,10 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Get("/group/member", server.HandleMargeDeviceGroupMember)
})
r.Post("/group", server.HandleMargeAddGroup)
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
r.Delete("/device/{device}", server.HandleMargeRemoveDevice)
})
@@ -846,6 +850,10 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Get("/devices/{device}/group/", server.HandleMargeDeviceGroup)
r.Get("/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
r.Get("/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
r.Post("/group", server.HandleMargeAddGroup)
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
r.Get("/devices/{device}/presets", server.HandleMargePresets)
r.Get("/devices/{device}/recents", server.HandleMargeRecents)
+6
View File
@@ -1,5 +1,6 @@
CONNECT /oauth/* handlers.(*Server).HandleBoseProxy-fm
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
DELETE /oauth/* handlers.(*Server).HandleBoseProxy-fm
DELETE /setup/devices/{deviceId} handlers.(*Server).HandleRemoveDevice-fm
DELETE /setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
@@ -7,6 +8,7 @@ DELETE /setup/interactions/sessions handlers.(
DELETE /setup/interactions/sessions/{session} handlers.(*Server).HandleDeleteSession-fm
DELETE /setup/parity-mismatches handlers.(*Server).HandleClearParityMismatches-fm
DELETE /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeRemovePreset-fm
DELETE /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
GET / handlers.(*Server).HandleRoot-fm
GET /accounts/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
GET /accounts/{account}/devices/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
@@ -84,6 +86,8 @@ PATCH /oauth/* handlers.(
POST /accounts/{account}/devices handlers.(*Server).HandleMargeAddDevice-fm
POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeAddRecent-fm
POST /accounts/{account}/group handlers.(*Server).HandleMargeAddGroup-fm
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
POST /bmx/orion/v1/playback/station/{data} handlers.(*Server).HandleOrionPlayback-fm
POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm
POST /bmx/tunein/v1/token handlers.(*Server).HandleTuneInToken-fm
@@ -120,6 +124,8 @@ POST /streaming/account/{account}/device/ handlers.(
POST /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeAddDevice-fm
POST /streaming/account/{account}/device/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
POST /streaming/account/{account}/device/{device}/recent handlers.(*Server).HandleMargeAddRecent-fm
POST /streaming/account/{account}/group handlers.(*Server).HandleMargeAddGroup-fm
POST /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
POST /streaming/account/{account}/source handlers.(*Server).HandleMargeAddSource-fm
POST /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeUpdateDeviceSettings-fm
POST /streaming/music/musicprovider/{providerID}/is_eligible handlers.(*Server).HandleMusicProviderIsEligible-fm
+25
View File
@@ -0,0 +1,25 @@
package models
import "encoding/xml"
// Group represents a stereo pair of two ST10 SoundTouch speakers.
type Group struct {
XMLName xml.Name `xml:"group"`
ID string `xml:"id,attr,omitempty"`
Name string `xml:"name"`
MasterDeviceID string `xml:"masterDeviceId"`
Roles GroupRoles `xml:"roles"`
SenderIPAddress string `xml:"senderIPAddress,omitempty"`
}
// GroupRoles contains the role assignments for devices in a group.
type GroupRoles struct {
Roles []GroupRole `xml:"groupRole"`
}
// GroupRole describes the role (LEFT or RIGHT) of a single device in a group.
type GroupRole struct {
DeviceID string `xml:"deviceId"`
Role string `xml:"role"`
IPAddress string `xml:"ipAddress,omitempty"`
}
+126
View File
@@ -10,6 +10,7 @@ import (
"encoding/xml"
"fmt"
"io"
"math/rand"
"os"
"path/filepath"
"sort"
@@ -1906,3 +1907,128 @@ func (ds *DataStore) ClearDNSDiscoveries() error {
return os.Remove(path)
}
// groupFilePath returns the on-disk path for a group file.
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 !exists(ds.groupFilePath(account, id)) {
return id
}
}
}
// GetGroupForDevice returns the group containing the given device, or nil if ungrouped.
func (ds *DataStore) GetGroupForDevice(account, deviceID string) (*models.Group, error) {
ds.fileMutex.RLock()
defer ds.fileMutex.RUnlock()
dir := ds.AccountDevicesDir(account)
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
for _, e := range entries {
if e.IsDir() || !strings.HasPrefix(e.Name(), "Group_") || !strings.HasSuffix(e.Name(), ".xml") {
continue
}
data, readErr := os.ReadFile(filepath.Join(dir, e.Name()))
if readErr != nil {
continue
}
var g models.Group
if unmarshalErr := xml.Unmarshal(data, &g); unmarshalErr != nil {
continue
}
for _, role := range g.Roles.Roles {
if role.DeviceID == deviceID {
return &g, nil
}
}
}
return nil, nil
}
// AddGroup saves a new group to disk and returns its generated ID.
func (ds *DataStore) AddGroup(account string, group *models.Group) (string, error) {
ds.fileMutex.Lock()
defer ds.fileMutex.Unlock()
dir := ds.AccountDevicesDir(account)
if err := os.MkdirAll(dir, 0755); err != nil {
return "", err
}
id := ds.generateGroupID(account)
group.ID = id
data, err := xml.MarshalIndent(group, "", " ")
if err != nil {
return "", err
}
return id, ds.atomicWriteFile(ds.groupFilePath(account, id), append([]byte(xml.Header), data...))
}
// ModifyGroup updates the name of an existing group and returns the updated group.
func (ds *DataStore) ModifyGroup(account, groupID, newName string) (*models.Group, error) {
ds.fileMutex.Lock()
defer ds.fileMutex.Unlock()
path := ds.groupFilePath(account, groupID)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("group %s not found", groupID)
}
return nil, err
}
var g models.Group
if err := xml.Unmarshal(data, &g); err != nil {
return nil, err
}
g.Name = newName
updated, err := xml.MarshalIndent(&g, "", " ")
if err != nil {
return nil, err
}
if err := ds.atomicWriteFile(path, append([]byte(xml.Header), updated...)); err != nil {
return nil, err
}
return &g, nil
}
// DeleteGroup removes a group from disk.
func (ds *DataStore) DeleteGroup(account, groupID string) error {
ds.fileMutex.Lock()
defer ds.fileMutex.Unlock()
err := os.Remove(ds.groupFilePath(account, groupID))
if os.IsNotExist(err) {
return fmt.Errorf("group %s not found", groupID)
}
return err
}
@@ -1,6 +1,7 @@
package handlers
import (
"bufio"
"io/fs"
"os"
"path/filepath"
@@ -22,6 +23,8 @@ func TestDocsConsistency(t *testing.T) {
summaryText := string(summaryContent)
docsIgnore := readDocsIgnore(t, filepath.Join(projectRoot, ".docsignore"))
// List of directories to check
dirsToCheck := []string{".", "guides", "reference", "analysis"}
@@ -48,6 +51,13 @@ func TestDocsConsistency(t *testing.T) {
return nil
}
// Skip files listed in .docsignore at the project root
for _, skip := range docsIgnore {
if strings.HasSuffix(path, filepath.FromSlash(skip)) {
return nil
}
}
// Get relative path from docs/
relPath, err := filepath.Rel(docsDir, path)
if err != nil {
@@ -69,3 +79,31 @@ func TestDocsConsistency(t *testing.T) {
}
}
}
// readDocsIgnore reads a .docsignore file and returns the non-empty, non-comment lines.
// If the file does not exist it returns nil without failing the test.
func readDocsIgnore(t *testing.T, path string) []string {
t.Helper()
f, err := os.Open(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
t.Fatalf("Failed to read %s: %v", path, err)
}
defer func() { _ = f.Close() }()
var patterns []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
patterns = append(patterns, line)
}
if err := scanner.Err(); err != nil {
t.Fatalf("Error reading %s: %v", path, err)
}
return patterns
}
+122 -6
View File
@@ -679,26 +679,142 @@ func (s *Server) HandleMargeStreamingToken(w http.ResponseWriter, _ *http.Reques
_, _ = w.Write(data)
}
// HandleMargeDeviceGroup returns grouping information for a device (empty group by default).
func (s *Server) HandleMargeDeviceGroup(w http.ResponseWriter, _ *http.Request) {
// Native firmware expects vnd.bose.streaming content type
// HandleMargeDeviceGroup returns grouping information for a device.
func (s *Server) HandleMargeDeviceGroup(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
device := chi.URLParam(r, "device")
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
group, err := s.ds.GetGroupForDevice(account, device)
if err != nil || group == nil {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(constants.XMLHeader + `<group/>`))
return
}
data, err := xml.Marshal(group)
if err != nil {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(constants.XMLHeader + `<group/>`))
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(constants.XMLHeader + `<group/>`))
_, _ = w.Write([]byte(constants.XMLHeader))
_, _ = w.Write(data)
}
// HandleMargeDeviceGroupServer returns grouping server information (404 by default if not a server).
func (s *Server) HandleMargeDeviceGroupServer(w http.ResponseWriter, r *http.Request) {
// Not in a group as server
http.NotFound(w, r)
}
// HandleMargeDeviceGroupMember returns grouping member information (404 by default if not a member).
func (s *Server) HandleMargeDeviceGroupMember(w http.ResponseWriter, r *http.Request) {
// Not in a group as member
http.NotFound(w, r)
}
// HandleMargeAddGroup creates a new stereo group for an account.
func (s *Server) HandleMargeAddGroup(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
if !validatePathID(account) {
http.Error(w, "Invalid account ID", http.StatusBadRequest)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusInternalServerError)
return
}
var group models.Group
if err := xml.Unmarshal(body, &group); err != nil {
http.Error(w, "Invalid XML", http.StatusBadRequest)
return
}
id, err := s.ds.AddGroup(account, &group)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data, err := xml.Marshal(&group)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
w.Header().Set("Location", s.serverURL+"/account/"+account+"/group/"+id)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(constants.XMLHeader))
_, _ = w.Write(data)
}
// HandleMargeModifyGroup updates the name of an existing stereo group.
func (s *Server) HandleMargeModifyGroup(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
groupID := chi.URLParam(r, "groupId")
if !validatePathID(account) || !validatePathID(groupID) {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusInternalServerError)
return
}
var req models.Group
if err := xml.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid XML", http.StatusBadRequest)
return
}
updated, err := s.ds.ModifyGroup(account, groupID, req.Name)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
data, err := xml.Marshal(updated)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
_, _ = w.Write([]byte(constants.XMLHeader))
_, _ = w.Write(data)
}
// HandleMargeDeleteGroup removes a stereo group.
func (s *Server) HandleMargeDeleteGroup(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
groupID := chi.URLParam(r, "groupId")
if !validatePathID(account) || !validatePathID(groupID) {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
if err := s.ds.DeleteGroup(account, groupID); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
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>`))
}
// HandleMusicProviderIsEligible returns the music provider eligibility.
func (s *Server) HandleMusicProviderIsEligible(w http.ResponseWriter, _ *http.Request) {
// For now, we return false as seen in the interaction sample.
+174
View File
@@ -1563,3 +1563,177 @@ func TestMargeAdvancedFeatures(t *testing.T) {
}
})
}
func TestMargeGroupCRUD(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-group-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
r, _ := setupRouter("http://localhost:8001", ds)
ts := httptest.NewServer(r)
defer ts.Close()
account := "ACC001"
device1 := "AABBCCDDEEFF"
device2 := "112233445566"
groupXML := `<?xml version="1.0" encoding="UTF-8"?>
<group>
<name>Living Room Stereo</name>
<masterDeviceId>` + device1 + `</masterDeviceId>
<roles>
<groupRole><deviceId>` + device1 + `</deviceId><role>LEFT</role><ipAddress>192.168.1.10</ipAddress></groupRole>
<groupRole><deviceId>` + device2 + `</deviceId><role>RIGHT</role><ipAddress>192.168.1.11</ipAddress></groupRole>
</roles>
<senderIPAddress>192.168.1.10</senderIPAddress>
</group>`
var groupID string
t.Run("GET device group returns empty group before creation", func(t *testing.T) {
res, err := http.Get(ts.URL + "/marge/streaming/account/" + account + "/device/" + device1 + "/group")
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected 200, got %d", res.StatusCode)
}
body, _ := io.ReadAll(res.Body)
if !strings.Contains(string(body), "<group") {
t.Errorf("Expected <group> element, got: %s", body)
}
})
t.Run("POST group creates a new group and returns 201 with ID", func(t *testing.T) {
res, err := http.Post(
ts.URL+"/marge/streaming/account/"+account+"/group",
"application/xml",
bytes.NewBufferString(groupXML),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(res.Body)
t.Fatalf("Expected 201 Created, got %d: %s", res.StatusCode, body)
}
body, _ := io.ReadAll(res.Body)
if !strings.Contains(string(body), `<group `) {
t.Errorf("Response missing <group> with id attr: %s", body)
}
// Parse out the group ID from the response XML
type groupResp struct {
ID string `xml:"id,attr"`
}
var gr groupResp
if err := xml.Unmarshal(body, &gr); err != nil {
t.Fatalf("Failed to unmarshal group response: %v", err)
}
if gr.ID == "" {
t.Fatalf("Response group has no ID: %s", body)
}
groupID = gr.ID
})
t.Run("GET device group returns the group after creation", func(t *testing.T) {
res, err := http.Get(ts.URL + "/marge/streaming/account/" + account + "/device/" + device1 + "/group")
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected 200, got %d", res.StatusCode)
}
body, _ := io.ReadAll(res.Body)
if !strings.Contains(string(body), "Living Room Stereo") {
t.Errorf("Expected group name in response: %s", body)
}
})
t.Run("POST group/{groupId} renames the group", func(t *testing.T) {
if groupID == "" {
t.Skip("No group ID from prior subtest")
}
modXML := `<group><name>Bedroom Stereo</name><masterDeviceId>` + device1 + `</masterDeviceId></group>`
req, _ := http.NewRequest(http.MethodPost,
ts.URL+"/marge/streaming/account/"+account+"/group/"+groupID,
bytes.NewBufferString(modXML),
)
req.Header.Set("Content-Type", "application/xml")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
body, _ := io.ReadAll(res.Body)
t.Fatalf("Expected 200, got %d: %s", res.StatusCode, body)
}
body, _ := io.ReadAll(res.Body)
if !strings.Contains(string(body), "Bedroom Stereo") {
t.Errorf("Expected updated name in response: %s", body)
}
})
t.Run("DELETE group/{groupId} removes the group", func(t *testing.T) {
if groupID == "" {
t.Skip("No group ID from prior subtest")
}
req, _ := http.NewRequest(http.MethodDelete,
ts.URL+"/marge/streaming/account/"+account+"/group/"+groupID,
nil,
)
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
body, _ := io.ReadAll(res.Body)
t.Fatalf("Expected 200, got %d: %s", res.StatusCode, body)
}
})
t.Run("DELETE group/{groupId} returns 404 for missing group", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodDelete,
ts.URL+"/marge/streaming/account/"+account+"/group/9999999",
nil,
)
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusNotFound {
t.Errorf("Expected 404, got %d", res.StatusCode)
}
})
t.Run("GET device group is empty after deletion", func(t *testing.T) {
res, err := http.Get(ts.URL + "/marge/streaming/account/" + account + "/device/" + device1 + "/group")
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
body, _ := io.ReadAll(res.Body)
// Should be back to empty <group/>
if strings.Contains(string(body), "Bedroom Stereo") {
t.Errorf("Group should be gone after deletion: %s", body)
}
})
}
+6
View File
@@ -58,6 +58,9 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
r.Get("/account/{account}/device/{device}/group/", server.HandleMargeDeviceGroup)
r.Get("/account/{account}/device/{device}/group/server", server.HandleMargeDeviceGroupServer)
r.Get("/account/{account}/device/{device}/group/member", server.HandleMargeDeviceGroupMember)
r.Post("/account/{account}/group", server.HandleMargeAddGroup)
r.Post("/account/{account}/group/{groupId}", server.HandleMargeModifyGroup)
r.Delete("/account/{account}/group/{groupId}", server.HandleMargeDeleteGroup)
r.Post("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
r.Get("/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
r.Get("/account/{account}/full", server.HandleMargeAccountFull)
@@ -87,6 +90,9 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
r.Get("/{account}/devices/{device}/group/", server.HandleMargeDeviceGroup)
r.Get("/{account}/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
r.Get("/{account}/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
r.Post("/{account}/group", server.HandleMargeAddGroup)
r.Post("/{account}/group/{groupId}", server.HandleMargeModifyGroup)
r.Delete("/{account}/group/{groupId}", server.HandleMargeDeleteGroup)
}
// Setup Marge for tests