diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go
index 20dcb4a..6c89084 100644
--- a/cmd/soundtouch-service/main.go
+++ b/cmd/soundtouch-service/main.go
@@ -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) {
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index 765f0b2..14c3173 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -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)
diff --git a/docs/reference/CLOUD-API.md b/docs/reference/CLOUD-API.md
new file mode 100644
index 0000000..1b6be4f
--- /dev/null
+++ b/docs/reference/CLOUD-API.md
@@ -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.
diff --git a/pkg/models/models.go b/pkg/models/models.go
index 83846e6..631a73a 100644
--- a/pkg/models/models.go
+++ b/pkg/models/models.go
@@ -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"`
+}
diff --git a/pkg/service/handlers/handlers_marge.go b/pkg/service/handlers/handlers_marge.go
index 26fca83..8324f01 100644
--- a/pkg/service/handlers/handlers_marge.go
+++ b/pkg/service/handlers/handlers_marge.go
@@ -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"
diff --git a/pkg/service/handlers/handlers_marge_stockholm_test.go b/pkg/service/handlers/handlers_marge_stockholm_test.go
new file mode 100644
index 0000000..3020d76
--- /dev/null
+++ b/pkg/service/handlers/handlers_marge_stockholm_test.go
@@ -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), "12345") {
+ 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(""))
+ 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(""))
+ 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(""))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ t.Errorf("Expected status OK, got %v", res.Status)
+ }
+ })
+}
diff --git a/pkg/service/handlers/handlers_stats.go b/pkg/service/handlers/handlers_stats.go
index dd54822..839d19d 100644
--- a/pkg/service/handlers/handlers_stats.go
+++ b/pkg/service/handlers/handlers_stats.go
@@ -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)
diff --git a/pkg/service/handlers/handlers_stats_test.go b/pkg/service/handlers/handlers_stats_test.go
index 669d611..5478631 100644
--- a/pkg/service/handlers/handlers_stats_test.go
+++ b/pkg/service/handlers/handlers_stats_test.go
@@ -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)
+ }
+ })
}
diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go
index 75c7d5d..b7efbd3 100644
--- a/pkg/service/handlers/main_test.go
+++ b/pkg/service/handlers/main_test.go
@@ -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