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:
Tobias Gesellchen
2026-01-08 23:32:18 +01:00
parent 5caad90d51
commit de2ff3550f
13 changed files with 1694 additions and 13 deletions
+30
View File
@@ -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()
+360
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" ?><name>Sound Machinechen</name>
+39
View File
@@ -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>