feat: implement Stockholm-related cloud API emulation - Added handlers for Stockholm app events (/v1/stapp, /v1/scmudc) - Implemented account profile, password management, and device settings endpoints - Added Go models for new API responses and requests - Created docs/reference/CLOUD-API.md and updated SUMMARY.md - Added comprehensive unit tests for all new handlers - Updated ueberboese-api.yaml with new endpoints and schemas

This commit is contained in:
Tobias Gesellchen
2026-02-15 20:20:47 +01:00
parent ed2d8680e4
commit 742484568e
9 changed files with 423 additions and 0 deletions
+14
View File
@@ -492,6 +492,20 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
})
r.Route("/customer", func(r chi.Router) {
r.Get("/account/{account}", server.HandleMargeAccountProfile)
r.Post("/account/{account}", server.HandleMargeUpdateAccountProfile)
r.Post("/account/{account}/password", server.HandleMargeChangePassword)
})
r.Route("/v1", func(r chi.Router) {
r.Post("/stapp/{deviceId}", server.HandleAppEvents)
r.Post("/scmudc/{deviceId}", server.HandleAppEvents)
})
r.Route("/streaming/stats", func(r chi.Router) {
+1
View File
@@ -24,6 +24,7 @@
## Technical Reference
* [API Cookbook](reference/API-COOKBOOK.md)
* [API Endpoints](reference/API-ENDPOINTS.md)
* [Cloud API Emulation](reference/CLOUD-API.md)
* [System Endpoints](reference/SYSTEM-ENDPOINTS.md)
* [Speaker Endpoint](reference/SPEAKER-ENDPOINT.md)
* [WebSocket Events](reference/WEBSOCKET-EVENTS.md)
+73
View File
@@ -0,0 +1,73 @@
# Bose SoundTouch Cloud API Emulation (Marge/BMX/Stats)
This document describes the cloud-emulation APIs provided by the SoundTouch service. These APIs mimic the Bose cloud services (Marge, BMX, Stats) that SoundTouch devices and the SoundTouch controller application (Stockholm) interact with.
## Marge API (Account & Configuration)
Base path: `/marge`
### GET /streaming/sourceproviders
Retrieves a list of available streaming source providers.
### GET /accounts/{accountId}/full
Retrieves the full account configuration including sources, presets, and devices.
### GET /streaming/account/{accountId}/emailaddress
Retrieves the email address associated with the account.
### GET /streaming/device_setting/account/{accountId}/device/{deviceId}/device_settings
Retrieves settings for a specific device (e.g., clock format).
### POST /streaming/device_setting/account/{accountId}/device/{deviceId}/device_settings
Updates settings for a specific device.
### POST /accounts/{accountId}/devices/{deviceId}/presets/{presetNumber}
Updates a preset for a device.
### POST /accounts/{accountId}/devices/{deviceId}/recents
Adds an item to the device's recently played history.
### POST /accounts/{accountId}/devices
Adds a device to the account.
### DELETE /accounts/{accountId}/devices/{deviceId}
Removes a device from the account.
## Customer API (Profile & Password)
Base path: `/customer`
### GET /account/{accountId}
Retrieves the customer account profile.
### POST /account/{accountId}
Updates the customer account profile.
### POST /account/{accountId}/password
Changes the account password.
## Analytics & Stats API
Base path: `/v1` (App Events) or `/streaming/stats` (Device Stats)
### POST /v1/stapp/{deviceId}
Endpoint called by Bose SoundTouch mobile and web applications (Stockholm) to submit event data.
### POST /v1/scmudc/{deviceId}
Endpoint equivalent to `/v1/stapp/{deviceId}` sometimes used by apps or devices.
### POST /streaming/stats/usage
Endpoint used by physical devices to report usage statistics.
### POST /streaming/stats/error
Endpoint used by physical devices to report error statistics.
## BMX API (Streaming & Registry)
Base path: `/bmx`
### GET /registry/v1/services
Retrieves the registry of available streaming services.
### GET /tunein/v1/playback/station/{stationID}
Retrieves playback information for a TuneIn station.
+67
View File
@@ -240,3 +240,70 @@ type DeviceEvent struct {
MonoTime int64 `json:"monoTime"`
Data map[string]interface{} `json:"data"`
}
// DeviceEventsRequest represents a request containing multiple device events (stapp/scmudc).
type DeviceEventsRequest struct {
Envelope struct {
MonoTime int64 `json:"monoTime"`
PayloadProtocolVersion string `json:"payloadProtocolVersion"`
PayloadType string `json:"payloadType"`
ProtocolVersion string `json:"protocolVersion"`
Time string `json:"time"`
UniqueID string `json:"uniqueId"`
} `json:"envelope"`
Payload struct {
DeviceInfo struct {
BoseID string `json:"boseID"`
DeviceID string `json:"deviceID"`
DeviceType string `json:"deviceType"`
SoftwareVersion string `json:"softwareVersion"`
} `json:"deviceInfo"`
Events []struct {
Data map[string]interface{} `json:"data"`
Time string `json:"time"`
Type string `json:"type"`
} `json:"events"`
} `json:"payload"`
}
// DeviceSettingsResponse represents device settings.
type DeviceSettingsResponse struct {
XMLName xml.Name `xml:"deviceSettings"`
Settings []DeviceSetting `xml:"deviceSetting"`
}
// DeviceSetting represents a single device setting.
type DeviceSetting struct {
Name string `xml:"name"`
Value string `xml:"value"`
}
// AccountProfileResponse represents a customer account profile.
type AccountProfileResponse struct {
XMLName xml.Name `xml:"customer"`
AccountID string `xml:"accountID"`
Email string `xml:"email"`
FirstName string `xml:"firstName"`
LastName string `xml:"lastName"`
CountryCode string `xml:"countryCode"`
LanguageCode string `xml:"languageCode"`
Street string `xml:"street"`
City string `xml:"city"`
PostalCode string `xml:"postalCode"`
State string `xml:"state"`
Phone string `xml:"phone"`
MarketingOptIn bool `xml:"marketingOptIn"`
}
// ChangePasswordRequest represents a request to change the account password.
type ChangePasswordRequest struct {
XMLName xml.Name `xml:"passwordChange"`
OldPassword string `xml:"oldPassword"`
NewPassword string `xml:"newPassword"`
}
// EmailAddressResponse represents the account email address.
type EmailAddressResponse struct {
XMLName xml.Name `xml:"emailAddress"`
Email string `xml:",chardata"`
}
+79
View File
@@ -60,6 +60,85 @@ func (s *Server) HandleMargePowerOn(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}
// HandleMargeAccountProfile returns the account profile.
func (s *Server) HandleMargeAccountProfile(w http.ResponseWriter, r *http.Request) {
accountID := chi.URLParam(r, "account")
// Mock profile data
profile := models.AccountProfileResponse{
AccountID: accountID,
Email: "user@example.com",
FirstName: "SoundTouch",
LastName: "User",
CountryCode: "US",
LanguageCode: "en",
}
data, err := xml.MarshalIndent(profile, "", " ")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(xml.Header))
_, _ = w.Write(data)
}
// HandleMargeUpdateAccountProfile updates the account profile.
func (s *Server) HandleMargeUpdateAccountProfile(w http.ResponseWriter, _ *http.Request) {
// Stub implementation
w.WriteHeader(http.StatusOK)
}
// HandleMargeChangePassword changes the account password.
func (s *Server) HandleMargeChangePassword(w http.ResponseWriter, _ *http.Request) {
// Stub implementation
w.WriteHeader(http.StatusOK)
}
// HandleMargeGetEmailAddress returns the account email address.
func (s *Server) HandleMargeGetEmailAddress(w http.ResponseWriter, _ *http.Request) {
resp := models.EmailAddressResponse{
Email: "user@example.com",
}
data, err := xml.MarshalIndent(resp, "", " ")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(xml.Header))
_, _ = w.Write(data)
}
// HandleMargeGetDeviceSettings returns device settings.
func (s *Server) HandleMargeGetDeviceSettings(w http.ResponseWriter, _ *http.Request) {
resp := models.DeviceSettingsResponse{
Settings: []models.DeviceSetting{
{Name: "CLOCK_FORMAT", Value: "24HR"},
},
}
data, err := xml.MarshalIndent(resp, "", " ")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(xml.Header))
_, _ = w.Write(data)
}
// HandleMargeUpdateDeviceSettings updates device settings.
func (s *Server) HandleMargeUpdateDeviceSettings(w http.ResponseWriter, _ *http.Request) {
// Stub implementation
w.WriteHeader(http.StatusOK)
}
// HandleMargeSoftwareUpdate returns the Marge software update information.
func (s *Server) HandleMargeSoftwareUpdate(w http.ResponseWriter, r *http.Request) {
etag := "default-embedded"
@@ -0,0 +1,102 @@
package handlers
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestMargeStockholmHandlers(t *testing.T) {
r, _ := setupRouter("http://localhost:8001", nil)
ts := httptest.NewServer(r)
defer ts.Close()
t.Run("HandleMargeAccountProfile GET", func(t *testing.T) {
res, err := http.Get(ts.URL + "/customer/account/12345")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
body, _ := io.ReadAll(res.Body)
if !strings.Contains(string(body), "<accountID>12345</accountID>") {
t.Errorf("Response missing account ID: %s", string(body))
}
})
t.Run("HandleMargeUpdateAccountProfile POST", func(t *testing.T) {
res, err := http.Post(ts.URL+"/customer/account/12345", "application/xml", strings.NewReader("<profile/>"))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
})
t.Run("HandleMargeChangePassword POST", func(t *testing.T) {
res, err := http.Post(ts.URL+"/customer/account/12345/password", "application/xml", strings.NewReader("<password/>"))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
})
t.Run("HandleMargeGetEmailAddress GET", func(t *testing.T) {
res, err := http.Get(ts.URL + "/marge/streaming/account/12345/emailaddress")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
body, _ := io.ReadAll(res.Body)
if !strings.Contains(string(body), "user@example.com") {
t.Errorf("Response missing email: %s", string(body))
}
})
t.Run("HandleMargeGetDeviceSettings GET", func(t *testing.T) {
res, err := http.Get(ts.URL + "/marge/streaming/device_setting/account/123/device/DEV1/device_settings")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
body, _ := io.ReadAll(res.Body)
if !strings.Contains(string(body), "CLOCK_FORMAT") {
t.Errorf("Response missing settings: %s", string(body))
}
})
t.Run("HandleMargeUpdateDeviceSettings POST", func(t *testing.T) {
res, err := http.Post(ts.URL+"/marge/streaming/device_setting/account/123/device/DEV1/device_settings", "application/xml", strings.NewReader("<settings/>"))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status OK, got %v", res.Status)
}
})
}
+37
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/go-chi/chi/v5"
)
// HandleUsageStats handles Marge usage stats uploads.
@@ -49,6 +50,42 @@ func (s *Server) HandleUsageStats(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
// HandleAppEvents handles events from the Bose SoundTouch app (stapp/scmudc).
func (s *Server) HandleAppEvents(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
var req models.DeviceEventsRequest
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid app events format", http.StatusBadRequest)
return
}
deviceID := req.Envelope.UniqueID
if deviceID == "" {
deviceID = chi.URLParam(r, "deviceId")
}
for _, e := range req.Payload.Events {
event := models.DeviceEvent{
Type: e.Type,
Time: e.Time,
MonoTime: req.Envelope.MonoTime,
Data: e.Data,
}
if event.Time == "" {
event.Time = time.Now().Format(time.RFC3339)
}
s.ds.AddDeviceEvent(deviceID, event)
}
w.WriteHeader(http.StatusOK)
}
// HandleErrorStats handles Marge error stats uploads.
func (s *Server) HandleErrorStats(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
@@ -63,4 +63,44 @@ func TestStatsHandlers(t *testing.T) {
t.Error("Error stats file was not created")
}
})
t.Run("HandleAppEvents", func(t *testing.T) {
jsonData := `{
"envelope": {
"monoTime": 12345,
"payloadProtocolVersion": "3.1",
"payloadType": "stapp",
"protocolVersion": "1.0",
"time": "2023-10-27T10:00:00Z",
"uniqueId": "device789"
},
"payload": {
"deviceInfo": {
"deviceID": "device789"
},
"events": [
{
"type": "APP_OPEN",
"time": "2023-10-27T10:00:01Z",
"data": {"foo": "bar"}
}
]
}
}`
req := httptest.NewRequest("POST", "/v1/stapp/device789", bytes.NewBufferString(jsonData))
w := httptest.NewRecorder()
s.HandleAppEvents(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status OK, got %d", w.Code)
}
events := ds.GetDeviceEvents("device789")
if len(events) == 0 {
t.Error("App events were not recorded")
} else if events[0].Type != "APP_OPEN" {
t.Errorf("Expected event type APP_OPEN, got %s", events[0].Type)
}
})
}
+10
View File
@@ -43,6 +43,16 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
})
// Setup Customer for tests
r.Route("/customer", func(r chi.Router) {
r.Get("/account/{account}", server.HandleMargeAccountProfile)
r.Post("/account/{account}", server.HandleMargeUpdateAccountProfile)
r.Post("/account/{account}/password", server.HandleMargeChangePassword)
})
// Setup Setup for tests