mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
Add /streaming/account/{account}/devices (#148)
This commit is contained in:
@@ -140,6 +140,7 @@ test-http-client:
|
||||
/workdir/set_preset_5.http \
|
||||
/workdir/post_recent.http \
|
||||
/workdir/get_recents.http \
|
||||
/workdir/get_account_devices.http \
|
||||
/workdir/get_account_sources.http \
|
||||
/workdir/get_full_account.http \
|
||||
/workdir/get_group.http \
|
||||
|
||||
@@ -742,6 +742,7 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
r.Get("/full", server.HandleMargeAccountFull)
|
||||
r.Get("/sources", server.HandleMargeAccountSources)
|
||||
r.Get("/devices", server.HandleMargeAccountDevices)
|
||||
r.Get("/provider_settings", server.HandleMargeProviderSettings)
|
||||
|
||||
r.Route("/device", func(r chi.Router) {
|
||||
@@ -788,6 +789,8 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Route("/accounts", func(r chi.Router) {
|
||||
r.Route("/{account}", func(r chi.Router) {
|
||||
r.Get("/full", server.HandleMargeAccountFull)
|
||||
r.Get("/sources", server.HandleMargeAccountSources)
|
||||
r.Get("/devices", server.HandleMargeAccountDevices)
|
||||
|
||||
r.Post("/devices", server.HandleMargeAddDevice)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ DELETE /setup/interactions/sessions/{session} handlers.(
|
||||
DELETE /setup/parity-mismatches handlers.(*Server).HandleClearParityMismatches-fm
|
||||
DELETE /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeRemovePreset-fm
|
||||
GET / handlers.(*Server).HandleRoot-fm
|
||||
GET /accounts/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
|
||||
GET /accounts/{account}/devices/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
GET /accounts/{account}/devices/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
GET /accounts/{account}/devices/{device}/group/member handlers.(*Server).HandleMargeDeviceGroupMember-fm
|
||||
@@ -15,6 +16,7 @@ GET /accounts/{account}/devices/{device}/group/server handlers.(
|
||||
GET /accounts/{account}/devices/{device}/presets handlers.(*Server).HandleMargePresets-fm
|
||||
GET /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeRecents-fm
|
||||
GET /accounts/{account}/full handlers.(*Server).HandleMargeAccountFull-fm
|
||||
GET /accounts/{account}/sources handlers.(*Server).HandleMargeAccountSources-fm
|
||||
GET /bmx/registry/v1/services handlers.(*Server).HandleBMXRegistry-fm
|
||||
GET /bmx/registry/v1/servicesAvailability handlers.(*Server).HandleBMXServicesAvailability-fm
|
||||
GET /bmx/tunein/v1/navigate handlers.(*Server).HandleTuneInNavigate-fm
|
||||
@@ -61,6 +63,7 @@ GET /streaming/account/{account}/device/{device}/group/server handlers.(
|
||||
GET /streaming/account/{account}/device/{device}/presets handlers.(*Server).HandleMargePresets-fm
|
||||
GET /streaming/account/{account}/device/{device}/recent handlers.(*Server).HandleMargeRecents-fm
|
||||
GET /streaming/account/{account}/device/{device}/recents handlers.(*Server).HandleMargeRecents-fm
|
||||
GET /streaming/account/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
|
||||
GET /streaming/account/{account}/emailaddress handlers.(*Server).HandleMargeGetEmailAddress-fm
|
||||
GET /streaming/account/{account}/full handlers.(*Server).HandleMargeAccountFull-fm
|
||||
GET /streaming/account/{account}/provider_settings handlers.(*Server).HandleMargeProviderSettings-fm
|
||||
|
||||
+23
-5
@@ -756,18 +756,36 @@ type AccountSourcesResponse struct {
|
||||
Sources []FullResponseSource `xml:"source"`
|
||||
}
|
||||
|
||||
// AccountDevicesResponse represents the response from /streaming/account/{accountId}/devices.
|
||||
type AccountDevicesResponse struct {
|
||||
XMLName xml.Name `xml:"devices"`
|
||||
Devices []MargeAccountDevice `xml:"device"`
|
||||
ProviderSettings []ProviderSetting `xml:"providerSettings>providerSetting"`
|
||||
}
|
||||
|
||||
// MargeAccountDevice represents a device specifically for the /devices response.
|
||||
// It matches the structure in 06_orig.xml, which is a subset of AccountDevice.
|
||||
type MargeAccountDevice struct {
|
||||
DeviceID string `json:"device_id" xml:"deviceid,attr"`
|
||||
AttachedProduct *AttachedProduct `json:"attached_product" xml:"attachedProduct"`
|
||||
CreatedOn string `json:"created_on" xml:"createdOn"`
|
||||
IPAddress string `json:"ip_address" xml:"ipaddress"`
|
||||
Name string `json:"name" xml:"name"`
|
||||
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
|
||||
}
|
||||
|
||||
// AccountDevice represents a device in the account response.
|
||||
type AccountDevice struct {
|
||||
DeviceID string `json:"device_id" xml:"deviceid,attr"`
|
||||
AttachedProduct *AttachedProduct `json:"attached_product" xml:"attachedProduct"`
|
||||
CreatedOn string `json:"created_on" xml:"createdOn"`
|
||||
FirmwareVersion string `json:"firmware_version" xml:"firmwareVersion"`
|
||||
FirmwareVersion string `json:"firmware_version" xml:"firmwareVersion,omitempty"`
|
||||
IPAddress string `json:"ip_address" xml:"ipaddress"`
|
||||
Name string `json:"name" xml:"name"`
|
||||
Presets []FullResponsePreset `json:"presets" xml:"presets>preset"`
|
||||
Presets []FullResponsePreset `json:"presets" xml:"presets>preset,omitempty"`
|
||||
ProductCode string `json:"product_code" xml:"-"`
|
||||
Recents []FullResponseRecent `json:"recents" xml:"recents>recent"`
|
||||
SerialNumber string `json:"serial_number" xml:"serialNumber"`
|
||||
Recents []FullResponseRecent `json:"recents" xml:"recents>recent,omitempty"`
|
||||
SerialNumber string `json:"serial_number" xml:"serialNumber,omitempty"`
|
||||
DeviceSerialNumber string `json:"device_serial_number,omitempty" xml:"-"`
|
||||
MacAddress string `json:"mac_address,omitempty" xml:"-"`
|
||||
DiscoveryMethod string `json:"discovery_method,omitempty" xml:"-"`
|
||||
@@ -777,7 +795,7 @@ type AccountDevice struct {
|
||||
// AttachedProduct represents product information for a device.
|
||||
type AttachedProduct struct {
|
||||
ProductCode string `json:"product_code" xml:"product_code,attr"`
|
||||
Components []ServiceComponent `json:"components" xml:"components>component"`
|
||||
Components []ServiceComponent `json:"components" xml:"components>component,omitempty"`
|
||||
ProductLabel string `json:"product_label" xml:"productlabel"`
|
||||
SerialNumber string `json:"serial_number" xml:"serialnumber"`
|
||||
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
|
||||
|
||||
@@ -191,6 +191,29 @@ func (s *Server) HandleMargeAccountSources(w http.ResponseWriter, r *http.Reques
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeAccountDevices returns the Marge account devices.
|
||||
func (s *Server) HandleMargeAccountDevices(w http.ResponseWriter, r *http.Request) {
|
||||
account := chi.URLParam(r, "account")
|
||||
|
||||
device := r.URL.Query().Get("device")
|
||||
|
||||
etag := strconv.FormatInt(s.ds.GetETagForAccount(account, device), 10)
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := marge.AccountDevicesToXML(s.ds, account)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.1+xml")
|
||||
w.Header()["ETag"] = []string{etag}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargePowerOn handles the Marge power on request.
|
||||
func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
|
||||
@@ -346,6 +346,70 @@ func TestMargeAccountSources(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeAccountDevices(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
account := "12345"
|
||||
deviceID := "DEV1"
|
||||
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
|
||||
_ = os.MkdirAll(deviceDir, 0755)
|
||||
|
||||
// Mock DeviceInfo.json
|
||||
deviceInfo := models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
Name: "Test Device",
|
||||
IPAddress: "192.168.1.100",
|
||||
DeviceSerialNumber: "ABCDE12345",
|
||||
ProductCode: "SoundTouch 20",
|
||||
ProductSerialNumber: "066802942560222AE",
|
||||
}
|
||||
_ = ds.SaveDeviceInfo(account, deviceID, &deviceInfo)
|
||||
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
res, err := http.Get(ts.URL + "/streaming/account/" + account + "/devices")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
|
||||
contentType := res.Header.Get("Content-Type")
|
||||
if contentType != "application/vnd.bose.streaming-v1.1+xml" {
|
||||
t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.1+xml, got %v", contentType)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
bodyStr := string(body)
|
||||
|
||||
// Verify current XML structure produced by marge.go
|
||||
expectedSnippets := []string{
|
||||
"<devices>",
|
||||
"<device deviceid=\"DEV1\">",
|
||||
"<name>Test Device</name>",
|
||||
"<ipaddress>192.168.1.100</ipaddress>",
|
||||
"<providerSettings>",
|
||||
"ELIGIBLE_FOR_TRIAL",
|
||||
}
|
||||
|
||||
for _, snippet := range expectedSnippets {
|
||||
if !strings.Contains(bodyStr, snippet) {
|
||||
t.Errorf("Response missing expected snippet [%s]: %s", snippet, bodyStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeAccountSourcesNoDevices(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
|
||||
@@ -62,6 +62,7 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Get("/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
r.Get("/account/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Get("/account/{account}/sources", server.HandleMargeAccountSources)
|
||||
r.Get("/account/{account}/devices", server.HandleMargeAccountDevices)
|
||||
r.Get("/software/update/account/{account}", server.HandleMargeSoftwareUpdate)
|
||||
r.Post("/account", server.HandleMargeCreateAccount)
|
||||
r.Post("/account/login", server.HandleMargeLogin)
|
||||
@@ -69,6 +70,8 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
|
||||
accountsRoutes := func(r chi.Router) {
|
||||
r.Get("/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Get("/{account}/sources", server.HandleMargeAccountSources)
|
||||
r.Get("/{account}/devices", server.HandleMargeAccountDevices)
|
||||
r.Get("/{account}/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Put("/{account}/devices/{device}/preset/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
|
||||
@@ -878,6 +878,52 @@ func AccountSourcesToXML(ds *datastore.DataStore, account string) ([]byte, error
|
||||
return append([]byte(constants.XMLHeader), data...), nil
|
||||
}
|
||||
|
||||
// AccountDevicesToXML generates the account devices XML.
|
||||
func AccountDevicesToXML(ds *datastore.DataStore, account string) ([]byte, error) {
|
||||
devicesDir := ds.AccountDevicesDir(account)
|
||||
|
||||
entries, err := os.ReadDir(devicesDir)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
devices, _ := getAccountDevices(ds, account, entries)
|
||||
|
||||
var margeDevices []models.MargeAccountDevice
|
||||
|
||||
for i := range devices {
|
||||
d := &devices[i]
|
||||
margeDevices = append(margeDevices, models.MargeAccountDevice{
|
||||
DeviceID: d.DeviceID,
|
||||
AttachedProduct: d.AttachedProduct,
|
||||
CreatedOn: d.CreatedOn,
|
||||
IPAddress: d.IPAddress,
|
||||
Name: d.Name,
|
||||
UpdatedOn: d.UpdatedOn,
|
||||
})
|
||||
}
|
||||
|
||||
resp := models.AccountDevicesResponse{
|
||||
Devices: margeDevices,
|
||||
}
|
||||
|
||||
// Fill provider settings
|
||||
fullResp := &models.AccountFullResponse{}
|
||||
fillDefaultProviderSettings(account, fullResp)
|
||||
fillAccountInfo(ds, account, fullResp)
|
||||
resp.ProviderSettings = fullResp.ProviderSettings
|
||||
|
||||
data, err := xml.Marshal(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parity: use self-closing tags for empty components
|
||||
data = bytes.ReplaceAll(data, []byte("<components></components>"), []byte("<components/>"))
|
||||
|
||||
return append([]byte(constants.XMLHeader), data...), nil
|
||||
}
|
||||
|
||||
// AccountFullToXML generates a complete account XML with devices, presets, and recents.
|
||||
func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
|
||||
devicesDir := ds.AccountDevicesDir(account)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
### GET /streaming/account/{accountId}/devices
|
||||
GET {{host}}/streaming/account/{{accountId}}/devices
|
||||
Accept: application/vnd.bose.streaming-v1.1+xml
|
||||
Authorization: Bearer dummy-token
|
||||
|
||||
> {%
|
||||
client.test("Response is 200 OK", function() {
|
||||
client.assert(response.status === 200, "Response status is not 200");
|
||||
});
|
||||
|
||||
client.test("Content-Type is correct", function() {
|
||||
client.assert(response.contentType.mimeType === "application/vnd.bose.streaming-v1.1+xml", "Wrong content type");
|
||||
});
|
||||
|
||||
client.test("Response is XML and contains devices", function() {
|
||||
const doc = response.body;
|
||||
const devices = doc.documentElement;
|
||||
client.assert(devices.nodeName === "devices", "Root element is not 'devices'");
|
||||
|
||||
const deviceList = devices.getElementsByTagName("device");
|
||||
client.assert(deviceList.length === 1, "Expected 1 device element, found " + deviceList.length);
|
||||
|
||||
const expectedDeviceId = "B05ECAFE";
|
||||
const expectedName = "SoundTouch-11";
|
||||
const expectedIp = "192.168.1.100";
|
||||
|
||||
const device = deviceList.item(0);
|
||||
client.assert(device.getAttribute("deviceid") === expectedDeviceId, "Wrong device ID");
|
||||
|
||||
const nameElements = device.getElementsByTagName("name");
|
||||
client.assert(nameElements.length > 0, "Missing 'name' element for device " + expectedDeviceId);
|
||||
client.assert(nameElements.item(0).textContent === expectedName, "Wrong name for device " + expectedDeviceId);
|
||||
|
||||
const ipElements = device.getElementsByTagName("ipaddress");
|
||||
client.assert(ipElements.length > 0, "Missing 'ipaddress' element for device " + expectedDeviceId);
|
||||
client.assert(ipElements.item(0).textContent === expectedIp, "Wrong IP address for device " + expectedDeviceId);
|
||||
|
||||
const attachedProducts = device.getElementsByTagName("attachedProduct");
|
||||
client.assert(attachedProducts.length > 0, "Missing 'attachedProduct' for device " + expectedDeviceId);
|
||||
|
||||
const providerSettings = devices.getElementsByTagName("providerSettings");
|
||||
client.assert(providerSettings.length > 0, "Missing 'providerSettings' element");
|
||||
const settingList = providerSettings.item(0).getElementsByTagName("providerSetting");
|
||||
client.assert(settingList.length === 2, "Expected 2 providerSetting elements, found " + settingList.length);
|
||||
|
||||
const expectedBoseId = "7654321";
|
||||
for (let i = 0; i < settingList.length; i++) {
|
||||
const setting = settingList.item(i);
|
||||
const boseIdElements = setting.getElementsByTagName("boseId");
|
||||
client.assert(boseIdElements.length > 0, "Missing 'boseId' element in providerSetting " + i);
|
||||
client.assert(boseIdElements.item(0).textContent === expectedBoseId, "Wrong boseId in providerSetting " + i);
|
||||
}
|
||||
});
|
||||
%}
|
||||
@@ -1,5 +1,5 @@
|
||||
### GET /streaming/account/{accountId}/sources
|
||||
GET {{host}}/streaming/account/3230304/sources
|
||||
GET {{host}}/streaming/account/{{accountId}}/sources
|
||||
Accept: application/vnd.bose.streaming-v1.1+xml
|
||||
Authorization: Bearer dummy-token
|
||||
|
||||
@@ -18,23 +18,26 @@ Authorization: Bearer dummy-token
|
||||
client.assert(sources.nodeName === "sources", "Root element is not 'sources'");
|
||||
|
||||
const sourceList = sources.getElementsByTagName("source");
|
||||
client.assert(sourceList.length === 4, "Expected 4 source elements, found " + sourceList.length);
|
||||
client.assert(sourceList.length >= 4, "Expected at least 4 source elements, found " + sourceList.length);
|
||||
|
||||
const expectedIds = ["10001", "10002", "10003", "10004"];
|
||||
for (let i = 0; i < sourceList.length; i++) {
|
||||
const source = sourceList.item(i);
|
||||
client.assert(source.getAttribute("id") === expectedIds[i], "Wrong source ID at index " + i);
|
||||
client.assert(source.getAttribute("type") === "Audio", "Wrong source type at index " + i);
|
||||
const sourceId = source.getAttribute("id");
|
||||
if (i < expectedIds.length) {
|
||||
client.assert(sourceId === expectedIds[i], "Wrong source ID at index " + i);
|
||||
}
|
||||
client.assert(source.getAttribute("type") === "Audio", "Wrong source type for source " + sourceId);
|
||||
|
||||
const credentials = source.getElementsByTagName("credential");
|
||||
client.assert(credentials.length > 0, "Missing credential for source " + expectedIds[i]);
|
||||
client.assert(credentials.length > 0, "Missing credential for source " + sourceId);
|
||||
const credential = credentials.item(0);
|
||||
client.assert(credential.getAttribute("type") === "token", "Wrong credential type for source " + expectedIds[i]);
|
||||
client.assert(credential.getAttribute("type").startsWith("token"), "Wrong credential type for source " + sourceId);
|
||||
|
||||
const expectedChildren = ["createdOn", "updatedOn", "name", "sourceproviderid", "sourcename", "sourceSettings", "username"];
|
||||
expectedChildren.forEach(childName => {
|
||||
const children = source.getElementsByTagName(childName);
|
||||
client.assert(children.length > 0, "Missing " + childName + " for source " + expectedIds[i]);
|
||||
client.assert(children.length > 0, "Missing " + childName + " for source " + sourceId);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"macAddress1": "B05ECAFE",
|
||||
"macAddress2": "B05ECAFF",
|
||||
"accountId": "7654321",
|
||||
"deviceName": "SoundTouch-12",
|
||||
"deviceName": "SoundTouch-11",
|
||||
"spotifyUserId": "13570",
|
||||
"spotifyToken": "example-spotify-token",
|
||||
"spotifyRefreshToken": "example-spotify-refresh-token",
|
||||
|
||||
@@ -8,7 +8,7 @@ Content-Type: application/vnd.bose.streaming-v1.2+xml
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<recent>
|
||||
<lastplayedat>2026-03-29T21:33:00+00:00</lastplayedat>
|
||||
<sourceid>14774275</sourceid>
|
||||
<sourceid>10004</sourceid>
|
||||
<name>SMOOTH JAZZ</name>
|
||||
<location>/v1/playback/station/s166521</location>
|
||||
<contentItemType>stationurl</contentItemType>
|
||||
|
||||
Reference in New Issue
Block a user