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
+265 -10
View File
@@ -16,15 +16,18 @@ import (
func main() {
var (
host = flag.String("host", "", "SoundTouch device host/IP address")
port = flag.Int("port", 8090, "SoundTouch device port")
timeout = flag.Duration("timeout", 10*time.Second, "Request timeout")
discover = flag.Bool("discover", false, "Discover SoundTouch devices via UPnP")
discoverAll = flag.Bool("discover-all", false, "Discover all SoundTouch devices and show info")
info = flag.Bool("info", false, "Get device information")
nowPlaying = flag.Bool("nowplaying", false, "Get current playback status")
sources = flag.Bool("sources", false, "Get available audio sources")
help = flag.Bool("help", false, "Show help")
host = flag.String("host", "", "SoundTouch device host/IP address")
port = flag.Int("port", 8090, "SoundTouch device port")
timeout = flag.Duration("timeout", 10*time.Second, "Request timeout")
discover = flag.Bool("discover", false, "Discover SoundTouch devices via UPnP")
discoverAll = flag.Bool("discover-all", false, "Discover all SoundTouch devices and show info")
info = flag.Bool("info", false, "Get device information")
nowPlaying = flag.Bool("nowplaying", false, "Get current playback status")
sources = flag.Bool("sources", false, "Get available audio sources")
name = flag.Bool("name", false, "Get device name")
capabilities = flag.Bool("capabilities", false, "Get device capabilities")
presets = flag.Bool("presets", false, "Get configured presets")
help = flag.Bool("help", false, "Show help")
)
flag.Parse()
@@ -35,7 +38,7 @@ func main() {
}
// If no specific action is requested, show help
if !*discover && !*discoverAll && !*info && !*nowPlaying && !*sources && *host == "" {
if !*discover && !*discoverAll && !*info && !*nowPlaying && !*sources && !*name && !*capabilities && !*presets && *host == "" {
printHelp()
return
}
@@ -80,6 +83,39 @@ func main() {
}
return
}
// Handle name
if *name {
if *host == "" {
log.Fatal("Host is required for name command. Use -host flag or -discover to find devices.")
}
if err := handleName(*host, *port, *timeout); err != nil {
log.Fatalf("Failed to get device name: %v", err)
}
return
}
// Handle capabilities
if *capabilities {
if *host == "" {
log.Fatal("Host is required for capabilities command. Use -host flag or -discover to find devices.")
}
if err := handleCapabilities(*host, *port, *timeout); err != nil {
log.Fatalf("Failed to get device capabilities: %v", err)
}
return
}
// Handle presets
if *presets {
if *host == "" {
log.Fatal("Host is required for presets command. Use -host flag or -discover to find devices.")
}
if err := handlePresets(*host, *port, *timeout); err != nil {
log.Fatalf("Failed to get presets: %v", err)
}
return
}
}
func printHelp() {
@@ -97,6 +133,9 @@ func printHelp() {
fmt.Println(" -info Get device information (requires -host)")
fmt.Println(" -nowplaying Get current playback status (requires -host)")
fmt.Println(" -sources Get available audio sources (requires -host)")
fmt.Println(" -name Get device name (requires -host)")
fmt.Println(" -capabilities Get device capabilities (requires -host)")
fmt.Println(" -presets Get configured presets (requires -host)")
fmt.Println(" -help Show this help message")
fmt.Println()
fmt.Println("Examples:")
@@ -105,6 +144,9 @@ func printHelp() {
fmt.Println(" soundtouch-cli -host 192.168.1.100 -info")
fmt.Println(" soundtouch-cli -host 192.168.1.100 -nowplaying")
fmt.Println(" soundtouch-cli -host 192.168.1.100 -sources")
fmt.Println(" soundtouch-cli -host 192.168.1.100 -name")
fmt.Println(" soundtouch-cli -host 192.168.1.100 -capabilities")
fmt.Println(" soundtouch-cli -host 192.168.1.100 -presets")
fmt.Println(" soundtouch-cli -host 192.168.1.100 -port 8090 -info")
}
@@ -469,3 +511,216 @@ func handleSources(host string, port int, timeout time.Duration) error {
return nil
}
func handleName(host string, port int, timeout time.Duration) error {
cfg, err := config.LoadFromEnv()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
// Override config with command line arguments if provided
if timeout > 0 {
cfg.HTTPTimeout = timeout
}
clientConfig := client.ClientConfig{
Host: host,
Port: port,
Timeout: cfg.HTTPTimeout,
UserAgent: cfg.UserAgent,
}
soundtouchClient := client.NewClient(clientConfig)
fmt.Printf("Getting device name from %s:%d...\n", host, port)
// Get device name
name, err := soundtouchClient.GetName()
if err != nil {
return fmt.Errorf("failed to get device name: %w", err)
}
// Display name information
fmt.Printf("Device Name: %s\n", name.GetName())
if name.IsEmpty() {
fmt.Printf("Warning: Device name is empty\n")
}
return nil
}
func handleCapabilities(host string, port int, timeout time.Duration) error {
cfg, err := config.LoadFromEnv()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
// Override config with command line arguments if provided
if timeout > 0 {
cfg.HTTPTimeout = timeout
}
clientConfig := client.ClientConfig{
Host: host,
Port: port,
Timeout: cfg.HTTPTimeout,
UserAgent: cfg.UserAgent,
}
soundtouchClient := client.NewClient(clientConfig)
fmt.Printf("Getting device capabilities from %s:%d...\n", host, port)
// Get device capabilities
capabilities, err := soundtouchClient.GetCapabilities()
if err != nil {
return fmt.Errorf("failed to get device capabilities: %w", err)
}
// Display capabilities information
fmt.Printf("Device Capabilities:\n")
fmt.Printf(" Device ID: %s\n", capabilities.DeviceID)
fmt.Println()
// System capabilities
systemCaps := capabilities.GetSystemCapabilities()
if len(systemCaps) > 0 {
fmt.Printf("System Features:\n")
for _, cap := range systemCaps {
fmt.Printf(" • %s\n", cap)
}
fmt.Println()
}
// Audio capabilities
audioCaps := capabilities.GetAudioCapabilities()
if len(audioCaps) > 0 {
fmt.Printf("Audio Features:\n")
for _, cap := range audioCaps {
fmt.Printf(" • %s\n", cap)
}
fmt.Println()
}
// Network capabilities
networkCaps := capabilities.GetNetworkCapabilities()
if len(networkCaps) > 0 {
fmt.Printf("Network Features:\n")
for _, cap := range networkCaps {
fmt.Printf(" • %s\n", cap)
}
// Show hosted wifi details if available
if capabilities.HasHostedWifiConfig() {
fmt.Printf(" Hosted WiFi Config:\n")
fmt.Printf(" • Port: %s\n", capabilities.GetHostedWifiPort())
fmt.Printf(" • Hosted by: %s\n", capabilities.GetHostedWifiHostedBy())
}
fmt.Println()
}
// Extended capabilities
capNames := capabilities.GetCapabilityNames()
if len(capNames) > 0 {
fmt.Printf("Extended Capabilities:\n")
for _, capName := range capNames {
cap := capabilities.GetCapabilityByName(capName)
fmt.Printf(" • %s", capName)
if cap.URL != "" {
fmt.Printf(" (%s)", cap.URL)
}
fmt.Println()
}
}
return nil
}
func handlePresets(host string, port int, timeout time.Duration) error {
cfg, err := config.LoadFromEnv()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
// Override config with command line arguments if provided
if timeout > 0 {
cfg.HTTPTimeout = timeout
}
clientConfig := client.ClientConfig{
Host: host,
Port: port,
Timeout: cfg.HTTPTimeout,
UserAgent: cfg.UserAgent,
}
soundtouchClient := client.NewClient(clientConfig)
fmt.Printf("Getting configured presets from %s:%d...\n", host, port)
// Get presets
presets, err := soundtouchClient.GetPresets()
if err != nil {
return fmt.Errorf("failed to get presets: %w", err)
}
// Display presets information
fmt.Printf("Configured Presets:\n")
if !presets.HasPresets() {
fmt.Printf(" No presets configured\n")
return nil
}
summary := presets.GetPresetsSummary()
fmt.Printf(" Used Slots: %d/6\n", summary["used"])
fmt.Printf(" Spotify Presets: %d\n", summary["spotify"])
fmt.Println()
// Show each configured preset
for _, preset := range presets.Preset {
if preset.IsEmpty() {
continue
}
fmt.Printf("Preset %d: %s\n", preset.ID, preset.GetDisplayName())
fmt.Printf(" Source: %s", preset.GetSource())
if preset.GetSourceAccount() != "" {
fmt.Printf(" (%s)", preset.GetSourceAccount())
}
fmt.Println()
if preset.GetContentType() != "" {
fmt.Printf(" Type: %s\n", preset.GetContentType())
}
if preset.HasTimestamps() {
if !preset.GetCreatedTime().IsZero() {
fmt.Printf(" Created: %s\n", preset.GetCreatedTime().Format("2006-01-02 15:04:05"))
}
if !preset.GetUpdatedTime().IsZero() {
fmt.Printf(" Updated: %s\n", preset.GetUpdatedTime().Format("2006-01-02 15:04:05"))
}
}
if preset.GetArtworkURL() != "" {
fmt.Printf(" Artwork: %s\n", preset.GetArtworkURL())
}
fmt.Println()
}
// Show empty slots
emptySlots := presets.GetEmptyPresetSlots()
if len(emptySlots) > 0 {
fmt.Printf("Available Slots: %v\n", emptySlots)
}
// Show most recent preset
if recent := presets.GetMostRecentPreset(); recent != nil {
fmt.Printf("Most Recent: Preset %d (%s)\n", recent.ID, recent.GetDisplayName())
}
return nil
}