diff --git a/Makefile b/Makefile
index 919dba5..920aa5a 100644
--- a/Makefile
+++ b/Makefile
@@ -121,6 +121,7 @@ test-http-client:
--env ci \
/workdir/create_account.http \
/workdir/register_device.http \
+ /workdir/customer_support.http \
/workdir/power_on.http \
/workdir/get_provider_settings.http \
/workdir/get_full_account.http \
diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go
index 4aa2f32..63574c5 100644
--- a/cmd/soundtouch-service/main.go
+++ b/cmd/soundtouch-service/main.go
@@ -679,6 +679,10 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Get("/sourceproviders", server.HandleMargeSourceProviders)
r.Post("/account", server.HandleMargeCreateAccount)
r.Post("/account/login", server.HandleMargeLogin)
+ r.Route("/account/{account}/device", func(r chi.Router) {
+ r.Post("/", server.HandleMargeAddDevice)
+ r.Post("/{device}", server.HandleMargeAddDevice)
+ })
r.Get("/account/{account}/device/{device}/recent", server.HandleMargeRecents)
r.Post("/account/{account}/device/{device}/recent", server.HandleMargeAddRecent)
r.Get("/account/{account}/device/{device}/presets", server.HandleMargePresets)
diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go
index 18cec93..9dea77b 100644
--- a/pkg/service/datastore/datastore.go
+++ b/pkg/service/datastore/datastore.go
@@ -707,40 +707,7 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
}
// Try to load existing device info to avoid overwriting existing details with empty values.
- existing, _ := ds.getDeviceInfoNoLock(account, device)
- if existing != nil {
- if info.Name == "" {
- info.Name = existing.Name
- }
-
- if info.ProductCode == "" {
- info.ProductCode = existing.ProductCode
- }
-
- if info.DeviceSerialNumber == "" {
- info.DeviceSerialNumber = existing.DeviceSerialNumber
- }
-
- if info.ProductSerialNumber == "" {
- info.ProductSerialNumber = existing.ProductSerialNumber
- }
-
- if info.FirmwareVersion == "" {
- info.FirmwareVersion = existing.FirmwareVersion
- }
-
- if info.IPAddress == "" {
- info.IPAddress = existing.IPAddress
- }
-
- if info.MacAddress == "" {
- info.MacAddress = existing.MacAddress
- }
-
- if info.DiscoveryMethod == "" {
- info.DiscoveryMethod = existing.DiscoveryMethod
- }
- }
+ ds.mergeWithExistingDeviceInfo(account, device, info)
dir := ds.AccountDeviceDir(account, device)
if err := os.MkdirAll(dir, 0755); err != nil {
@@ -749,12 +716,6 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
path := filepath.Join(dir, constants.DeviceInfoFile)
- type ComponentXML struct {
- ComponentCategory string `xml:"componentCategory"`
- SoftwareVersion string `xml:"softwareVersion,omitempty"`
- SerialNumber string `xml:"serialNumber,omitempty"`
- }
-
type NetworkInfoXML struct {
Type string `xml:"type,attr"`
IPAddress string `xml:"ipAddress"`
@@ -767,24 +728,13 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
Name string `xml:"name"`
Type string `xml:"type"`
ModuleType string `xml:"moduleType"`
- Components []ComponentXML `xml:"components>component"`
+ Components []componentXML `xml:"components>component"`
NetworkInfo []NetworkInfoXML `xml:"networkInfo"`
DiscoveryMethod string `xml:"discoveryMethod,omitempty"`
}
// Parsing product code back to type and moduleType (best effort)
- // Python: f"{type} {module_type}"
- devType := info.ProductCode
- moduleType := ""
-
- for i := 0; i < len(info.ProductCode); i++ {
- if info.ProductCode[i] == ' ' {
- devType = info.ProductCode[:i]
- moduleType = info.ProductCode[i+1:]
-
- break
- }
- }
+ devType, moduleType := ds.parseProductCode(info.ProductCode)
ix := InfoXML{
DeviceID: info.DeviceID,
@@ -798,27 +748,7 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
ix.DiscoveryMethod = "sync_full"
}
- for _, comp := range info.Components {
- ix.Components = append(ix.Components, ComponentXML{
- ComponentCategory: comp.Category,
- SoftwareVersion: comp.SoftwareVersion,
- SerialNumber: comp.SerialNumber,
- })
- }
-
- if len(ix.Components) == 0 {
- ix.Components = []ComponentXML{
- {
- ComponentCategory: "SCM",
- SoftwareVersion: info.FirmwareVersion,
- SerialNumber: info.DeviceSerialNumber,
- },
- {
- ComponentCategory: "PackagedProduct",
- SerialNumber: info.ProductSerialNumber,
- },
- }
- }
+ ix.Components = ds.buildComponentsXML(info)
ix.NetworkInfo = []NetworkInfoXML{
{
@@ -838,7 +768,104 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
return os.WriteFile(path, append(header, data...), 0644)
}
-// SaveAccountInfo saves account-level metadata to the datastore.
+func (ds *DataStore) mergeWithExistingDeviceInfo(account, device string, info *models.ServiceDeviceInfo) {
+ existing, _ := ds.getDeviceInfoNoLock(account, device)
+ if existing == nil {
+ return
+ }
+
+ if info.Name == "" {
+ info.Name = existing.Name
+ }
+
+ if info.ProductCode == "" {
+ info.ProductCode = existing.ProductCode
+ }
+
+ if info.DeviceSerialNumber == "" {
+ info.DeviceSerialNumber = existing.DeviceSerialNumber
+ }
+
+ if info.ProductSerialNumber == "" {
+ info.ProductSerialNumber = existing.ProductSerialNumber
+ }
+
+ if info.FirmwareVersion == "" {
+ info.FirmwareVersion = existing.FirmwareVersion
+ }
+
+ if info.IPAddress == "" {
+ info.IPAddress = existing.IPAddress
+ }
+
+ if info.MacAddress == "" {
+ info.MacAddress = existing.MacAddress
+ }
+
+ if info.DiscoveryMethod == "" {
+ info.DiscoveryMethod = existing.DiscoveryMethod
+ }
+}
+
+func (ds *DataStore) parseProductCode(productCode string) (string, string) {
+ devType := productCode
+ moduleType := ""
+
+ for i := 0; i < len(productCode); i++ {
+ if productCode[i] == ' ' {
+ devType = productCode[:i]
+ moduleType = productCode[i+1:]
+
+ break
+ }
+ }
+
+ return devType, moduleType
+}
+
+type componentXML struct {
+ ComponentCategory string `xml:"componentCategory"`
+ SoftwareVersion string `xml:"softwareVersion,omitempty"`
+ SerialNumber string `xml:"serialNumber,omitempty"`
+}
+
+func (ds *DataStore) buildComponentsXML(info *models.ServiceDeviceInfo) []componentXML {
+ var components []componentXML
+ for _, comp := range info.Components {
+ components = append(components, componentXML{
+ ComponentCategory: comp.Category,
+ SoftwareVersion: comp.SoftwareVersion,
+ SerialNumber: comp.SerialNumber,
+ })
+ }
+
+ if len(components) == 0 && (info.FirmwareVersion != "" || info.DeviceSerialNumber != "" || info.ProductSerialNumber != "") {
+ components = []componentXML{
+ {
+ ComponentCategory: "SCM",
+ SoftwareVersion: info.FirmwareVersion,
+ SerialNumber: info.DeviceSerialNumber,
+ },
+ {
+ ComponentCategory: "PackagedProduct",
+ SerialNumber: info.ProductSerialNumber,
+ },
+ }
+ } else if len(components) > 0 {
+ if info.FirmwareVersion != "" {
+ for i := range components {
+ if components[i].ComponentCategory == "SCM" {
+ components[i].SoftwareVersion = info.FirmwareVersion
+ break
+ }
+ }
+ }
+ }
+
+ return components
+}
+
+// SaveAccountInfo stores account-level metadata in the datastore.
func (ds *DataStore) SaveAccountInfo(accountID string, info *models.ServiceAccountInfo) error {
if ds == nil || ds.DataDir == "" || accountID == "" {
return nil
diff --git a/pkg/service/handlers/handlers_marge.go b/pkg/service/handlers/handlers_marge.go
index 9f9ca6a..b65cd7b 100644
--- a/pkg/service/handlers/handlers_marge.go
+++ b/pkg/service/handlers/handlers_marge.go
@@ -543,7 +543,7 @@ func (s *Server) HandleMargeCustomerSupport(w http.ResponseWriter, r *http.Reque
}
var req models.CustomerSupportRequest
- if err := xml.Unmarshal(body, &req); err != nil {
+ if err = xml.Unmarshal(body, &req); err != nil {
// Log error but might still return 200 as Bose expects
log.Printf("Failed to unmarshal CustomerSupportRequest: %v", err)
}
@@ -561,5 +561,34 @@ func (s *Server) HandleMargeCustomerSupport(w http.ResponseWriter, r *http.Reque
},
}
s.ds.AddDeviceEvent(req.Device.ID, event)
+
+ // Update DeviceInfo if possible
+ devices, err := s.ds.ListAllDevices()
+ if err == nil {
+ var account string
+
+ for i := range devices {
+ dev := &devices[i]
+ if dev.DeviceID == req.Device.ID {
+ account = dev.AccountID
+ break
+ }
+ }
+
+ if account != "" {
+ info, err := s.ds.GetDeviceInfo(account, req.Device.ID)
+ if err == nil && info != nil {
+ info.IPAddress = req.DiagnosticData.DeviceLandscape.IPAddress
+
+ info.FirmwareVersion = req.Device.FirmwareVersion
+ if len(req.DiagnosticData.DeviceLandscape.MacAddresses) > 0 {
+ info.MacAddress = req.DiagnosticData.DeviceLandscape.MacAddresses[0]
+ }
+
+ _ = s.ds.SaveDeviceInfo(account, req.Device.ID, info)
+ }
+ }
+ }
+
w.WriteHeader(http.StatusOK)
}
diff --git a/pkg/service/handlers/handlers_marge_test.go b/pkg/service/handlers/handlers_marge_test.go
index 86b0d94..95f7f65 100644
--- a/pkg/service/handlers/handlers_marge_test.go
+++ b/pkg/service/handlers/handlers_marge_test.go
@@ -1030,11 +1030,23 @@ func TestMargeAdvancedFeatures(t *testing.T) {
})
t.Run("CustomerSupport", func(t *testing.T) {
- payload := `
+ account := "A123"
+ deviceId := "587A628A4042"
+ macAddress := "AABBCCDDEEFF"
+ ipAddress := "192.168.1.100"
+ firmware := "27.0.6"
+
+ // Pre-register device
+ _ = ds.SaveDeviceInfo(account, deviceId, &models.ServiceDeviceInfo{
+ DeviceID: deviceId,
+ Name: "TestDevice",
+ })
+
+ payload := fmt.Sprintf(`
-
+
P123
- 27.0.6
+ %s
SN123
@@ -1042,10 +1054,13 @@ func TestMargeAdvancedFeatures(t *testing.T) {
Good
- 192.168.1.100
+
+ %s
+
+ %s
- `
+ `, deviceId, firmware, macAddress, ipAddress)
res, err := http.Post(ts.URL+"/marge/streaming/support/customersupport", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(payload))
if err != nil {
@@ -1063,17 +1078,15 @@ func TestMargeAdvancedFeatures(t *testing.T) {
}
// Verify event was recorded
- events := ds.GetDeviceEvents("587A628A4042")
+ events := ds.GetDeviceEvents(deviceId)
found := false
for _, e := range events {
if e.Type == "customer-support-upload" {
found = true
-
- if e.Data["firmware"] != "27.0.6" {
- t.Errorf("Expected firmware 27.0.6, got %v", e.Data["firmware"])
+ if e.Data["firmware"] != firmware {
+ t.Errorf("Expected firmware %s, got %v", firmware, e.Data["firmware"])
}
-
break
}
}
@@ -1081,6 +1094,21 @@ func TestMargeAdvancedFeatures(t *testing.T) {
if !found {
t.Error("Customer support event not found in event log")
}
+
+ // Verify DeviceInfo was updated
+ info, err := ds.GetDeviceInfo(account, deviceId)
+ if err != nil {
+ t.Fatalf("Failed to get device info: %v", err)
+ }
+ if info.IPAddress != ipAddress {
+ t.Errorf("Expected updated IP %s, got %s", ipAddress, info.IPAddress)
+ }
+ if info.MacAddress != macAddress {
+ t.Errorf("Expected updated MAC %s, got %s", macAddress, info.MacAddress)
+ }
+ if info.FirmwareVersion != firmware {
+ t.Errorf("Expected updated firmware %s, got %s", firmware, info.FirmwareVersion)
+ }
})
t.Run("AddRecent_Reproduction", func(t *testing.T) {
diff --git a/pkg/service/handlers/handlers_media.go b/pkg/service/handlers/handlers_media.go
index 03e0679..2d819af 100644
--- a/pkg/service/handlers/handlers_media.go
+++ b/pkg/service/handlers/handlers_media.go
@@ -20,6 +20,9 @@ var mediaFS embed.FS
//go:embed static/bmx_services.json
var bmxServicesJSON []byte
+// Upstream source available at https://worldwide.bose.com/updates/soundtouch?serialnumber=_serial_
+// which results in a redirect to https://downloads.bose.com/ced/soundtouch/mr4_22097fe2/index.xml
+//
//go:embed static/swupdate.xml
var swUpdateXML []byte
diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go
index b715afd..5e27d68 100644
--- a/pkg/service/handlers/main_test.go
+++ b/pkg/service/handlers/main_test.go
@@ -39,6 +39,10 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
streamingRoutes := func(r chi.Router) {
r.Get("/sourceproviders", server.HandleMargeSourceProviders)
+ r.Route("/account/{account}/device", func(r chi.Router) {
+ r.Post("/", server.HandleMargeAddDevice)
+ r.Post("/{device}", server.HandleMargeAddDevice)
+ })
r.Get("/account/{account}/device/{device}/recent", server.HandleMargeRecents)
r.Post("/account/{account}/device/{device}/recent", server.HandleMargeAddRecent)
r.Get("/account/{account}/device/{device}/presets", server.HandleMargePresets)
diff --git a/pkg/service/marge/marge.go b/pkg/service/marge/marge.go
index 1abc49e..8c32af6 100644
--- a/pkg/service/marge/marge.go
+++ b/pkg/service/marge/marge.go
@@ -971,16 +971,18 @@ func formatRecentResponse(recentObj *models.ServiceRecent, matchingSrc *models.C
// AddDeviceToAccount adds a new device to the specified account.
func AddDeviceToAccount(ds *datastore.DataStore, account string, sourceXML []byte) ([]byte, error) {
var newDeviceElem struct {
- DeviceID string `xml:"deviceid,attr"`
- Name string `xml:"name"`
+ DeviceID string `xml:"deviceid,attr"`
+ Name string `xml:"name"`
+ MACAddress string `xml:"macaddress"`
}
if err := xml.Unmarshal(sourceXML, &newDeviceElem); err != nil {
return nil, err
}
info := &models.ServiceDeviceInfo{
- DeviceID: newDeviceElem.DeviceID,
- Name: newDeviceElem.Name,
+ DeviceID: newDeviceElem.DeviceID,
+ Name: newDeviceElem.Name,
+ MacAddress: newDeviceElem.MACAddress,
// Other fields will be filled by discovery later or default
}
diff --git a/tests/integration/http-client/customer_support.http b/tests/integration/http-client/customer_support.http
new file mode 100644
index 0000000..37dfe58
--- /dev/null
+++ b/tests/integration/http-client/customer_support.http
@@ -0,0 +1,15 @@
+### POST /streaming/support/customersupport
+POST {{host}}/streaming/support/customersupport
+Host: streaming.bose.com
+Content-Type: application/vnd.bose.streaming-v1.2+xml
+User-Agent: Bose_Lisa/27.0.6
+Accept: application/vnd.bose.streaming-v1.2+xml
+Authorization: Bearer {{token}}
+
+{{serialNumber}}27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29{{serialNumber}}Excellent{{gatewayIp}}{{macAddress1}}{{macAddress2}}{{deviceIp}}Wireless
+
+> {%
+ client.test("Customer support data uploaded successfully", function() {
+ client.assert(response.status === 200, "Response status is not 200");
+ });
+%}
diff --git a/tests/integration/http-client/register_device.http b/tests/integration/http-client/register_device.http
index ecd7f63..6336ebb 100644
--- a/tests/integration/http-client/register_device.http
+++ b/tests/integration/http-client/register_device.http
@@ -6,6 +6,7 @@ Authorization: Bearer {{token}}
{{deviceName}}
+ {{macAddress1}}
> {%
@@ -17,3 +18,39 @@ Authorization: Bearer {{token}}
client.assert(device.getAttribute("deviceid") === client.variables.environment.get("deviceId"), "Response body should contain the deviceId");
});
%}
+
+### POST /streaming/account/{{accountId}}/device/ (Register Device Variant)
+POST {{host}}/streaming/account/{{accountId}}/device/
+Content-Type: application/vnd.bose.streaming-v1.2+xml
+Authorization: Bearer {{token}}
+
+
+
+ {{deviceName}}
+ {{macAddress1}}mac
+
+
+> {%
+ client.test("Device registered successfully (variant)", function() {
+ client.assert(response.status === 200 || response.status === 201, "Response status is not 200 or 201");
+ const doc = response.body;
+ const device = doc.getElementsByTagName("device")[0];
+ client.assert(device !== undefined, "Response body should contain ");
+ client.assert(device.getAttribute("deviceid") === client.variables.environment.get("deviceId"), "Response body should contain the deviceId");
+
+ const createdOn = device.getElementsByTagName("createdOn")[0];
+ client.assert(createdOn !== undefined, "Response body should contain ");
+ client.assert(createdOn.textContent.length > 0, "createdOn should not be empty");
+
+ const name = device.getElementsByTagName("name")[0];
+ client.assert(name !== undefined, "Response body should contain ");
+ client.assert(name.textContent === client.variables.environment.get("deviceName"), "name should match requested name");
+
+ const updatedOn = device.getElementsByTagName("updatedOn")[0];
+ client.assert(updatedOn !== undefined, "Response body should contain ");
+ client.assert(updatedOn.textContent === createdOn.textContent, "updatedOn should match createdOn for a new device");
+
+ const ipaddress = device.getElementsByTagName("ipaddress")[0];
+ client.assert(ipaddress !== undefined, "Response body should contain ");
+ });
+%}