From 742484568e26346283ee20c69cc1d3f1d3e087d7 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 15 Feb 2026 19:14:53 +0100 Subject: [PATCH] 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 --- cmd/soundtouch-service/main.go | 14 +++ docs/SUMMARY.md | 1 + docs/reference/CLOUD-API.md | 73 +++++++++++++ pkg/models/models.go | 67 ++++++++++++ pkg/service/handlers/handlers_marge.go | 79 ++++++++++++++ .../handlers/handlers_marge_stockholm_test.go | 102 ++++++++++++++++++ pkg/service/handlers/handlers_stats.go | 37 +++++++ pkg/service/handlers/handlers_stats_test.go | 40 +++++++ pkg/service/handlers/main_test.go | 10 ++ 9 files changed, 423 insertions(+) create mode 100644 docs/reference/CLOUD-API.md create mode 100644 pkg/service/handlers/handlers_marge_stockholm_test.go 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