mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 16:46:17 +00:00
Implement /name, /capabilities, and /presets informational endpoints
## New Endpoints ### GET /name ✅ - Simple device name retrieval with XML parsing - Helper methods for name validation and display - Real device name integration with anonymization ### GET /capabilities ✅ - Comprehensive device capabilities detection - Complex XML structure with nested network, DSP, and system configurations - Smart categorization: System Features, Audio Features, Network Features - Capability-specific helper methods (HasLRStereoCapability, HasDualModeNetwork, etc.) - Extended capabilities parsing with URLs and metadata ### GET /presets ✅ - Complete preset management with timestamps and metadata - Spotify playlist integration with anonymized account information - Smart filtering: by source, used/empty slots, most recent, oldest presets - Comprehensive analysis: preset summaries with source breakdowns - Time-based operations: creation/update timestamps with formatted display ## Device Introspection Features ### Capability Detection - System capabilities: Light Switch, Clock Display, BCO Reset, Power Saving - Audio capabilities: L/R Stereo support, DSP Mono/Stereo availability - Network capabilities: Dual Mode, WSAPI Proxy, Hosted WiFi Configuration - Extended capabilities: Custom endpoint discovery with URL mapping ### Preset Analysis - Usage pattern analysis (used vs empty slots) - Source distribution (Spotify, TuneIn, etc.) - Temporal analysis (most recent, oldest presets) - Content metadata extraction (artwork URLs, display names) ## Enhanced CLI Tool ### New Commands - Added -name command with simple device identification - Added -capabilities command with categorized feature display - Added -presets command with comprehensive preset analysis - Enhanced help system with all new command examples ### Rich Output Formatting - Capability categorization with bullet-point display - Preset timeline with creation/update timestamps - Smart metadata display (artwork, source accounts, content types) - Device-specific feature highlighting (different capabilities per device) ## Real Device Integration ### Multi-Device Testing - Device 192.168.178.28: SoundTouch 10 with Light Switch, Clock Display, Hosted WiFi - Device 192.168.178.35: SoundTouch 20 with L/R Stereo, Dual Mode networking - Verified capability differences between device models - Real preset data with anonymized Spotify account information ### Edge Case Handling - Non-responsive endpoints (/trackInfo timeout handling) - Empty preset configurations - Missing capability sections - Device-specific feature variations ## Quality & Testing ### Comprehensive Test Coverage - 15+ unit tests for XML models with real device response patterns - Client integration tests with mock HTTP servers - Edge case validation (empty names, missing capabilities, no presets) - Timestamp parsing and validation with Unix epoch conversion ### Production-Ready Features - Type-safe XML unmarshaling with custom validation - Robust error handling for network and parsing failures - Privacy protection with anonymized real device data - Documentation updates with real-world usage examples ## API Coverage Progress ✅ Complete Information Endpoints: - GET /info - Device information - GET /name - Device name - GET /capabilities - Device capabilities - GET /presets - Configured presets - GET /now_playing - Current playback status - GET /sources - Available audio sources 🔄 Next Phase - Control Endpoints: - POST /key - Media controls - GET/POST /volume - Volume management - WebSocket / - Real-time events Features: ✅ Comprehensive device introspection and capability detection ✅ Smart preset management with timeline analysis ✅ Multi-device support with hardware-specific feature detection ✅ Production-ready error handling and data validation ✅ Rich CLI interface with categorized output formatting ✅ Real device integration with privacy-protected test data
This commit is contained in:
@@ -96,6 +96,36 @@ func (c *Client) GetSources() (*models.Sources, error) {
|
||||
return &sources, nil
|
||||
}
|
||||
|
||||
// GetName retrieves the device name from the /name endpoint
|
||||
func (c *Client) GetName() (*models.Name, error) {
|
||||
var name models.Name
|
||||
err := c.get("/name", &name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get device name: %w", err)
|
||||
}
|
||||
return &name, nil
|
||||
}
|
||||
|
||||
// GetCapabilities retrieves device capabilities from the /capabilities endpoint
|
||||
func (c *Client) GetCapabilities() (*models.Capabilities, error) {
|
||||
var capabilities models.Capabilities
|
||||
err := c.get("/capabilities", &capabilities)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get device capabilities: %w", err)
|
||||
}
|
||||
return &capabilities, nil
|
||||
}
|
||||
|
||||
// GetPresets retrieves configured presets from the /presets endpoint
|
||||
func (c *Client) GetPresets() (*models.Presets, error) {
|
||||
var presets models.Presets
|
||||
err := c.get("/presets", &presets)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get presets: %w", err)
|
||||
}
|
||||
return &presets, nil
|
||||
}
|
||||
|
||||
// Ping checks if the device is reachable by calling /info
|
||||
func (c *Client) Ping() error {
|
||||
_, err := c.GetDeviceInfo()
|
||||
|
||||
@@ -682,6 +682,366 @@ func TestClient_GetSources_InvalidXML(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
responseFile string
|
||||
expectedError bool
|
||||
expectedName string
|
||||
}{
|
||||
{
|
||||
name: "valid device name",
|
||||
responseFile: "name_response.xml",
|
||||
expectedError: false,
|
||||
expectedName: "Sound Machinechen",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
t.Errorf("Expected GET request, got %s", r.Method)
|
||||
}
|
||||
|
||||
if r.URL.Path != "/name" {
|
||||
t.Errorf("Expected path /name, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
// Check headers
|
||||
if userAgent := r.Header.Get("User-Agent"); userAgent == "" {
|
||||
t.Error("Expected User-Agent header to be set")
|
||||
}
|
||||
|
||||
if accept := r.Header.Get("Accept"); accept != "application/xml" {
|
||||
t.Errorf("Expected Accept header 'application/xml', got '%s'", accept)
|
||||
}
|
||||
|
||||
// Read test data
|
||||
data, err := os.ReadFile(filepath.Join("testdata", tt.responseFile))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read test data: %v", err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(data)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Parse server URL to get host and port
|
||||
serverURL, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse server URL: %v", err)
|
||||
}
|
||||
|
||||
host := serverURL.Hostname()
|
||||
port, _ := strconv.Atoi(serverURL.Port())
|
||||
|
||||
client := NewClient(ClientConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Timeout: 5 * time.Second,
|
||||
UserAgent: "test-client",
|
||||
})
|
||||
|
||||
name, err := client.GetName()
|
||||
|
||||
if tt.expectedError {
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if name == nil {
|
||||
t.Fatal("Expected Name response but got nil")
|
||||
}
|
||||
|
||||
if name.GetName() != tt.expectedName {
|
||||
t.Errorf("Expected name '%s', got '%s'", tt.expectedName, name.GetName())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetCapabilities(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
responseFile string
|
||||
expectedError bool
|
||||
expectedDevice string
|
||||
hasLRStereo bool
|
||||
hasDualMode bool
|
||||
hasWSAPIProxy bool
|
||||
}{
|
||||
{
|
||||
name: "valid capabilities",
|
||||
responseFile: "capabilities_response.xml",
|
||||
expectedError: false,
|
||||
expectedDevice: "A81B6A536A98",
|
||||
hasLRStereo: true,
|
||||
hasDualMode: true,
|
||||
hasWSAPIProxy: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
t.Errorf("Expected GET request, got %s", r.Method)
|
||||
}
|
||||
|
||||
if r.URL.Path != "/capabilities" {
|
||||
t.Errorf("Expected path /capabilities, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
// Read test data
|
||||
data, err := os.ReadFile(filepath.Join("testdata", tt.responseFile))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read test data: %v", err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(data)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Parse server URL to get host and port
|
||||
serverURL, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse server URL: %v", err)
|
||||
}
|
||||
|
||||
host := serverURL.Hostname()
|
||||
port, _ := strconv.Atoi(serverURL.Port())
|
||||
|
||||
client := NewClient(ClientConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Timeout: 5 * time.Second,
|
||||
UserAgent: "test-client",
|
||||
})
|
||||
|
||||
capabilities, err := client.GetCapabilities()
|
||||
|
||||
if tt.expectedError {
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if capabilities == nil {
|
||||
t.Fatal("Expected Capabilities response but got nil")
|
||||
}
|
||||
|
||||
if capabilities.DeviceID != tt.expectedDevice {
|
||||
t.Errorf("Expected device ID '%s', got '%s'", tt.expectedDevice, capabilities.DeviceID)
|
||||
}
|
||||
|
||||
if capabilities.HasLRStereoCapability() != tt.hasLRStereo {
|
||||
t.Errorf("Expected HasLRStereoCapability() %v, got %v", tt.hasLRStereo, capabilities.HasLRStereoCapability())
|
||||
}
|
||||
|
||||
if capabilities.HasDualModeNetwork() != tt.hasDualMode {
|
||||
t.Errorf("Expected HasDualModeNetwork() %v, got %v", tt.hasDualMode, capabilities.HasDualModeNetwork())
|
||||
}
|
||||
|
||||
if capabilities.HasWSAPIProxy() != tt.hasWSAPIProxy {
|
||||
t.Errorf("Expected HasWSAPIProxy() %v, got %v", tt.hasWSAPIProxy, capabilities.HasWSAPIProxy())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetPresets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
responseFile string
|
||||
expectedError bool
|
||||
expectedCount int
|
||||
expectedUsed int
|
||||
expectedSpotify int
|
||||
}{
|
||||
{
|
||||
name: "valid presets",
|
||||
responseFile: "presets_response.xml",
|
||||
expectedError: false,
|
||||
expectedCount: 6,
|
||||
expectedUsed: 6,
|
||||
expectedSpotify: 6,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
t.Errorf("Expected GET request, got %s", r.Method)
|
||||
}
|
||||
|
||||
if r.URL.Path != "/presets" {
|
||||
t.Errorf("Expected path /presets, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
// Read test data
|
||||
data, err := os.ReadFile(filepath.Join("testdata", tt.responseFile))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read test data: %v", err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(data)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Parse server URL to get host and port
|
||||
serverURL, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse server URL: %v", err)
|
||||
}
|
||||
|
||||
host := serverURL.Hostname()
|
||||
port, _ := strconv.Atoi(serverURL.Port())
|
||||
|
||||
client := NewClient(ClientConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Timeout: 5 * time.Second,
|
||||
UserAgent: "test-client",
|
||||
})
|
||||
|
||||
presets, err := client.GetPresets()
|
||||
|
||||
if tt.expectedError {
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if presets == nil {
|
||||
t.Fatal("Expected Presets response but got nil")
|
||||
}
|
||||
|
||||
if presets.GetPresetCount() != tt.expectedCount {
|
||||
t.Errorf("Expected preset count %d, got %d", tt.expectedCount, presets.GetPresetCount())
|
||||
}
|
||||
|
||||
if len(presets.GetUsedPresetSlots()) != tt.expectedUsed {
|
||||
t.Errorf("Expected used count %d, got %d", tt.expectedUsed, len(presets.GetUsedPresetSlots()))
|
||||
}
|
||||
|
||||
if len(presets.GetSpotifyPresets()) != tt.expectedSpotify {
|
||||
t.Errorf("Expected Spotify count %d, got %d", tt.expectedSpotify, len(presets.GetSpotifyPresets()))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetName_ServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("Internal Server Error"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
serverURL, _ := url.Parse(server.URL)
|
||||
host := serverURL.Hostname()
|
||||
port, _ := strconv.Atoi(serverURL.Port())
|
||||
|
||||
client := NewClient(ClientConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Timeout: 5 * time.Second,
|
||||
})
|
||||
|
||||
_, err := client.GetName()
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error for server error response")
|
||||
}
|
||||
|
||||
expectedErrorMsg := "failed to get device name"
|
||||
if !strings.Contains(err.Error(), expectedErrorMsg) {
|
||||
t.Errorf("Expected error message to contain '%s', got '%s'", expectedErrorMsg, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetCapabilities_ServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("Internal Server Error"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
serverURL, _ := url.Parse(server.URL)
|
||||
host := serverURL.Hostname()
|
||||
port, _ := strconv.Atoi(serverURL.Port())
|
||||
|
||||
client := NewClient(ClientConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Timeout: 5 * time.Second,
|
||||
})
|
||||
|
||||
_, err := client.GetCapabilities()
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error for server error response")
|
||||
}
|
||||
|
||||
expectedErrorMsg := "failed to get device capabilities"
|
||||
if !strings.Contains(err.Error(), expectedErrorMsg) {
|
||||
t.Errorf("Expected error message to contain '%s', got '%s'", expectedErrorMsg, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetPresets_ServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("Internal Server Error"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
serverURL, _ := url.Parse(server.URL)
|
||||
host := serverURL.Hostname()
|
||||
port, _ := strconv.Atoi(serverURL.Port())
|
||||
|
||||
client := NewClient(ClientConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Timeout: 5 * time.Second,
|
||||
})
|
||||
|
||||
_, err := client.GetPresets()
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error for server error response")
|
||||
}
|
||||
|
||||
expectedErrorMsg := "failed to get presets"
|
||||
if !strings.Contains(err.Error(), expectedErrorMsg) {
|
||||
t.Errorf("Expected error message to contain '%s', got '%s'", expectedErrorMsg, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func loadTestData(t *testing.T, filename string) string {
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<capabilities deviceID="A81B6A536A98">
|
||||
<networkConfig>
|
||||
<dualMode>true</dualMode>
|
||||
<wsapiproxy>true</wsapiproxy>
|
||||
<allInterfacesSupported />
|
||||
<wlanInterfaces />
|
||||
<security />
|
||||
</networkConfig>
|
||||
<dspCapabilities>
|
||||
<dspMonoStereo available="false" />
|
||||
</dspCapabilities>
|
||||
<lightswitch>false</lightswitch>
|
||||
<clockDisplay>false</clockDisplay>
|
||||
<capability name="systemtimeout" url="/systemtimeout" info="" />
|
||||
<capability name="rebroadcastlatencymode" url="/rebroadcastlatencymode" info="" />
|
||||
<lrStereoCapable>true</lrStereoCapable>
|
||||
<bcoresetCapable>false</bcoresetCapable>
|
||||
<disablePowerSaving>true</disablePowerSaving>
|
||||
</capabilities>
|
||||
+1
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?><name>Sound Machinechen</name>
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<presets>
|
||||
<preset id="1" createdOn="1719128436" updatedOn="1728740382">
|
||||
<ContentItem source="SPOTIFY" type="tracklisturl" location="/playback/container/c3BvdGlmeTpwbGF5bGlzdDo1Mm5QaVJrbWVmSkZPeHh1M1ZTd1hh" sourceAccount="user@example.com" isPresetable="true">
|
||||
<itemName>My Playlist</itemName>
|
||||
<containerArt>https://i.scdn.co/image/ab67616d00001e025ff75c5d082fc50a3a74ad7b</containerArt>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<preset id="2" createdOn="1703353552" updatedOn="1743615710">
|
||||
<ContentItem source="SPOTIFY" type="tracklisturl" location="/playbook/container/c3BvdGlmeTpwbGF5bGlzdDoxV2dKT3EyWktYU1BTRGxDdWI1NERV" sourceAccount="user@example.com" isPresetable="true">
|
||||
<itemName>Chill Music Collection</itemName>
|
||||
<containerArt>https://i.scdn.co/image/ab67616d0000b273e07c8adc6fb49168dc8b7a2f</containerArt>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<preset id="3" createdOn="1585994240" updatedOn="1727112013">
|
||||
<ContentItem source="SPOTIFY" type="tracklisturl" location="/playbook/container/c3BvdGlmeTphbGJ1bTo3MnY3UTRtNkZ3bUlpWkV2QXZUY0hT" sourceAccount="user@example.com" isPresetable="true">
|
||||
<itemName>Kids Songs</itemName>
|
||||
<containerArt>https://i.scdn.co/image/ab67616d0000b273e32d3cf7356dacb162678d1f</containerArt>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<preset id="4" createdOn="1514712063" updatedOn="1745991460">
|
||||
<ContentItem source="SPOTIFY" type="tracklisturl" location="/playbook/container/c3BvdGlmeTphbGJ1bTo2QXI1SHhOV1h0dnJhcXM3Rkk3Yllx" sourceAccount="user@example.com" isPresetable="true">
|
||||
<itemName>Movie Soundtrack</itemName>
|
||||
<containerArt>https://i.scdn.co/image/ab67616d0000b273bc16d1eefe86b079c8805f8f</containerArt>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<preset id="5" createdOn="1509901730" updatedOn="1744629417">
|
||||
<ContentItem source="SPOTIFY" type="tracklisturl" location="/playbook/container/c3BvdGlmeTp0cmFjazo2M1RsOWsxc0g4dHpubjNicW9NdXlG" sourceAccount="user@example.com" isPresetable="true">
|
||||
<itemName>Pop Hits</itemName>
|
||||
<containerArt>https://i.scdn.co/image/ab67616d0000b2734214ddc9e33e76de6a8ee888</containerArt>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<preset id="6" createdOn="1585502139" updatedOn="1730021067">
|
||||
<ContentItem source="SPOTIFY" type="tracklisturl" location="/playbook/container/c3BvdGlmeTphbGJ1bTo3RjUwdWg3b0dpdG1BRVNjUktWNnBE" sourceAccount="user@example.com" isPresetable="true">
|
||||
<itemName>World Music</itemName>
|
||||
<containerArt>https://i.scdn.co/image/ab67616d0000b273f0e55478f4a15182405bcb47</containerArt>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
</presets>
|
||||
Reference in New Issue
Block a user