diff --git a/README.md b/README.md
index 2d6d605..41868c5 100644
--- a/README.md
+++ b/README.md
@@ -7,12 +7,14 @@ A modern Go library and CLI tool for interacting with Bose SoundTouch devices vi
### ✅ Implemented (Phase 1)
- **HTTP Client with XML Support**: Complete client for SoundTouch Web API
- **Device Information**: Get detailed device info via `/info` endpoint
+- **Device Name**: Get device name via `/name` endpoint
+- **Device Capabilities**: Get device capabilities via `/capabilities` endpoint
+- **Configured Presets**: Get preset configurations via `/presets` endpoint
- **Now Playing Status**: Get current playback information via `/now_playing` endpoint
- **Audio Sources**: Get available sources via `/sources` endpoint
- **UPnP Discovery**: Automatic device discovery on local network
- **Cross-Platform**: Works on Windows, macOS, Linux, and WASM
- **CLI Tool**: Command-line interface for testing and basic operations
-- **Comprehensive Tests**: Unit and integration tests with real device responses
- **Flexible Configuration**: Support for .env files and environment variables
- **Hybrid Discovery**: Combines UPnP discovery with configured device lists
@@ -127,6 +129,59 @@ soundtouch-cli -host 192.168.1.100 -sources
# Streaming Services: 3 ready
```
+#### Device Name
+```bash
+# Get device name
+soundtouch-cli -host 192.168.1.100 -name
+
+# Example output:
+# Device Name: Sound Machinechen
+```
+
+#### Device Capabilities
+```bash
+# Get device capabilities
+soundtouch-cli -host 192.168.1.100 -capabilities
+
+# Example output:
+# Device Capabilities:
+# Device ID: A81B6A536A98
+#
+# System Features:
+# • Power Saving Disabled
+#
+# Audio Features:
+# • L/R Stereo
+#
+# Network Features:
+# • Dual Mode
+# • WSAPI Proxy
+#
+# Extended Capabilities:
+# • systemtimeout (/systemtimeout)
+# • rebroadcastlatencymode (/rebroadcastlatencymode)
+```
+
+#### Configured Presets
+```bash
+# Get configured presets
+soundtouch-cli -host 192.168.1.100 -presets
+
+# Example output:
+# Configured Presets:
+# Used Slots: 6/6
+# Spotify Presets: 6
+#
+# Preset 1: My Playlist
+# Source: SPOTIFY (user@example.com)
+# Type: tracklisturl
+# Created: 2024-06-23 09:40:36
+# Updated: 2024-10-12 15:39:42
+# Artwork: https://i.scdn.co/image/...
+#
+# Most Recent: Preset 4 (Movie Soundtrack)
+```
+
### Go Library Usage
```go
@@ -191,6 +246,27 @@ func main() {
if sources.HasSpotify() {
fmt.Println("Spotify is available")
}
+
+ // Get device name
+ name, err := soundtouchClient.GetName()
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("Device: %s\n", name.GetName())
+
+ // Get device capabilities
+ capabilities, err := soundtouchClient.GetCapabilities()
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("L/R Stereo Support: %v\n", capabilities.HasLRStereoCapability())
+
+ // Get presets
+ presets, err := soundtouchClient.GetPresets()
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("Presets Used: %d/6\n", len(presets.GetUsedPresetSlots()))
}
```
@@ -261,6 +337,9 @@ make help
The SoundTouch Web API uses HTTP with XML payloads. Key endpoints include:
- `GET /info` - Device information ✅ Implemented
+- `GET /name` - Device name ✅ Implemented
+- `GET /capabilities` - Device capabilities ✅ Implemented
+- `GET /presets` - Configured presets ✅ Implemented
- `GET /now_playing` - Current playback status ✅ Implemented
- `GET /sources` - Available audio sources ✅ Implemented
- `POST /key` - Send key commands (play, pause, etc.)
diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go
index 60246db..c4ef894 100644
--- a/cmd/soundtouch-cli/main.go
+++ b/cmd/soundtouch-cli/main.go
@@ -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
+}
diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md
index fb68206..a72cc24 100644
--- a/docs/CLAUDE.md
+++ b/docs/CLAUDE.md
@@ -78,6 +78,7 @@ When creating test data for API endpoints, prefer real device responses over hyp
- **Usage**: Fetch real responses to create accurate test fixtures
- **Privacy**: Anonymize any personal data (account names, personal playlists, etc.)
- **Coverage**: Use multiple real devices to cover different response variations
+- **Non-responsive endpoints**: Some endpoints like `/trackInfo` may not respond or exist on all devices
## Additional Notes
diff --git a/docs/PLAN.md b/docs/PLAN.md
index 46c6b21..c825d43 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -310,6 +310,9 @@ func (c Config) Validate() error
- [x] DeviceInfo - Device information endpoint
- [x] NowPlaying - Current playback status endpoint
- [x] Sources - Available audio sources endpoint
+ - [x] Name - Device name endpoint
+ - [x] Capabilities - Device capabilities endpoint
+ - [x] Presets - Configured presets endpoint
- [x] Custom XML unmarshaling for enums
- [x] Validation and defaults
- [x] **CLI tool for testing** ✅ DONE
@@ -318,6 +321,9 @@ func (c Config) Validate() error
- [x] Device info retrieval
- [x] Now playing status
- [x] Audio sources listing
+ - [x] Device name retrieval
+ - [x] Device capabilities inspection
+ - [x] Preset configuration listing
- [x] **Unit tests with mocks** ✅ DONE
- [x] HTTP client tests
- [x] XML parsing tests
@@ -659,11 +665,13 @@ docker-compose up # Mock devices + web app
### Phase 1-2 (Foundation)
- ✅ Stable HTTP API connection to SoundTouch devices
-- ✅ XML model coverage for implemented APIs (DeviceInfo, NowPlaying, Sources)
+- ✅ XML model coverage for implemented APIs (DeviceInfo, NowPlaying, Sources, Name, Capabilities, Presets)
- ✅ Automatic device discovery via UPnP
-- ✅ Functional CLI tool with discovery, info, now playing, and sources commands
+- ✅ Functional CLI tool with discovery, info, now playing, sources, name, capabilities, and presets commands
- ✅ Now Playing endpoint with comprehensive status information
- ✅ Sources endpoint with filtering and categorization features
+- ✅ Device identification endpoints (name, capabilities)
+- ✅ Preset management with comprehensive analysis and filtering
### Phase 3-4 (Real-time & Web)
- ✅ WebSocket event streaming with reconnection
diff --git a/pkg/client/client.go b/pkg/client/client.go
index d77a9d6..4352bce 100644
--- a/pkg/client/client.go
+++ b/pkg/client/client.go
@@ -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()
diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go
index 55a7d14..097039b 100644
--- a/pkg/client/client_test.go
+++ b/pkg/client/client_test.go
@@ -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 {
diff --git a/pkg/client/testdata/capabilities_response.xml b/pkg/client/testdata/capabilities_response.xml
new file mode 100644
index 0000000..50ddc24
--- /dev/null
+++ b/pkg/client/testdata/capabilities_response.xml
@@ -0,0 +1,20 @@
+
+
+
+ true
+ true
+
+
+
+
+
+
+
+ false
+ false
+
+
+ true
+ false
+ true
+
diff --git a/pkg/client/testdata/name_response.xml b/pkg/client/testdata/name_response.xml
new file mode 100644
index 0000000..bc9acfd
--- /dev/null
+++ b/pkg/client/testdata/name_response.xml
@@ -0,0 +1 @@
+Sound Machinechen
diff --git a/pkg/client/testdata/presets_response.xml b/pkg/client/testdata/presets_response.xml
new file mode 100644
index 0000000..7c4f2b3
--- /dev/null
+++ b/pkg/client/testdata/presets_response.xml
@@ -0,0 +1,39 @@
+
+
+
+
+ My Playlist
+ https://i.scdn.co/image/ab67616d00001e025ff75c5d082fc50a3a74ad7b
+
+
+
+
+ Chill Music Collection
+ https://i.scdn.co/image/ab67616d0000b273e07c8adc6fb49168dc8b7a2f
+
+
+
+
+ Kids Songs
+ https://i.scdn.co/image/ab67616d0000b273e32d3cf7356dacb162678d1f
+
+
+
+
+ Movie Soundtrack
+ https://i.scdn.co/image/ab67616d0000b273bc16d1eefe86b079c8805f8f
+
+
+
+
+ Pop Hits
+ https://i.scdn.co/image/ab67616d0000b2734214ddc9e33e76de6a8ee888
+
+
+
+
+ World Music
+ https://i.scdn.co/image/ab67616d0000b273f0e55478f4a15182405bcb47
+
+
+
diff --git a/pkg/models/capabilities.go b/pkg/models/capabilities.go
new file mode 100644
index 0000000..461eb67
--- /dev/null
+++ b/pkg/models/capabilities.go
@@ -0,0 +1,201 @@
+package models
+
+import "encoding/xml"
+
+// Capabilities represents the response from /capabilities endpoint
+type Capabilities struct {
+ XMLName xml.Name `xml:"capabilities"`
+ DeviceID string `xml:"deviceID,attr"`
+ NetworkConfig *NetworkConfig `xml:"networkConfig,omitempty"`
+ DSPConfig *DSPConfig `xml:"dspCapabilities,omitempty"`
+ Lightswitch bool `xml:"lightswitch,omitempty"`
+ ClockDisplay bool `xml:"clockDisplay,omitempty"`
+ Capability []Capability `xml:"capability,omitempty"`
+ LRStereo bool `xml:"lrStereoCapable,omitempty"`
+ BCOReset bool `xml:"bcoresetCapable,omitempty"`
+ PowerSaving bool `xml:"disablePowerSaving,omitempty"`
+}
+
+// NetworkConfig represents network configuration capabilities
+type NetworkConfig struct {
+ HostedWifiConfig *HostedWifiConfig `xml:"hostedWifiConfigWebPage,omitempty"`
+ DualMode bool `xml:"dualMode,omitempty"`
+ WSAPIProxy bool `xml:"wsapiproxy,omitempty"`
+ AllInterfaceSupport *AllInterfaces `xml:"allInterfacesSupported,omitempty"`
+ WLANInterfaces *WLANInterfaces `xml:"wlanInterfaces,omitempty"`
+ Security *Security `xml:"security,omitempty"`
+}
+
+// HostedWifiConfig represents hosted wifi configuration settings
+type HostedWifiConfig struct {
+ HostedBy string `xml:"hostedBy,attr,omitempty"`
+ Generation string `xml:"generation,attr,omitempty"`
+ Port string `xml:"port,attr,omitempty"`
+ Enabled bool `xml:",chardata"`
+}
+
+// AllInterfaces represents all network interfaces support
+type AllInterfaces struct{}
+
+// WLANInterfaces represents WLAN interfaces configuration
+type WLANInterfaces struct{}
+
+// Security represents security configuration
+type Security struct{}
+
+// DSPConfig represents DSP capabilities
+type DSPConfig struct {
+ DSPMonoStereo *DSPMonoStereo `xml:"dspMonoStereo,omitempty"`
+}
+
+// DSPMonoStereo represents mono/stereo DSP capability
+type DSPMonoStereo struct {
+ Available bool `xml:"available,attr"`
+}
+
+// Capability represents an individual device capability
+type Capability struct {
+ Name string `xml:"name,attr"`
+ URL string `xml:"url,attr"`
+ Info string `xml:"info,attr"`
+}
+
+// HasLightswitch returns true if the device has a light switch
+func (c *Capabilities) HasLightswitch() bool {
+ return c.Lightswitch
+}
+
+// HasClockDisplay returns true if the device has a clock display
+func (c *Capabilities) HasClockDisplay() bool {
+ return c.ClockDisplay
+}
+
+// HasLRStereoCapability returns true if the device supports left/right stereo
+func (c *Capabilities) HasLRStereoCapability() bool {
+ return c.LRStereo
+}
+
+// HasBCOResetCapability returns true if the device supports BCO reset
+func (c *Capabilities) HasBCOResetCapability() bool {
+ return c.BCOReset
+}
+
+// HasPowerSavingDisabled returns true if power saving is disabled
+func (c *Capabilities) HasPowerSavingDisabled() bool {
+ return c.PowerSaving
+}
+
+// HasDualModeNetwork returns true if the device supports dual mode networking
+func (c *Capabilities) HasDualModeNetwork() bool {
+ return c.NetworkConfig != nil && c.NetworkConfig.DualMode
+}
+
+// HasWSAPIProxy returns true if the device supports WSAPI proxy
+func (c *Capabilities) HasWSAPIProxy() bool {
+ return c.NetworkConfig != nil && c.NetworkConfig.WSAPIProxy
+}
+
+// HasHostedWifiConfig returns true if the device supports hosted wifi configuration
+func (c *Capabilities) HasHostedWifiConfig() bool {
+ return c.NetworkConfig != nil &&
+ c.NetworkConfig.HostedWifiConfig != nil &&
+ c.NetworkConfig.HostedWifiConfig.Enabled
+}
+
+// GetHostedWifiPort returns the hosted wifi configuration port
+func (c *Capabilities) GetHostedWifiPort() string {
+ if c.HasHostedWifiConfig() {
+ return c.NetworkConfig.HostedWifiConfig.Port
+ }
+ return ""
+}
+
+// GetHostedWifiHostedBy returns who hosts the wifi configuration
+func (c *Capabilities) GetHostedWifiHostedBy() string {
+ if c.HasHostedWifiConfig() {
+ return c.NetworkConfig.HostedWifiConfig.HostedBy
+ }
+ return ""
+}
+
+// HasDSPMonoStereo returns true if DSP mono/stereo is available
+func (c *Capabilities) HasDSPMonoStereo() bool {
+ return c.DSPConfig != nil &&
+ c.DSPConfig.DSPMonoStereo != nil &&
+ c.DSPConfig.DSPMonoStereo.Available
+}
+
+// GetCapabilityByName returns a capability by name
+func (c *Capabilities) GetCapabilityByName(name string) *Capability {
+ for _, cap := range c.Capability {
+ if cap.Name == name {
+ return &cap
+ }
+ }
+ return nil
+}
+
+// HasCapability returns true if the device has the specified capability
+func (c *Capabilities) HasCapability(name string) bool {
+ return c.GetCapabilityByName(name) != nil
+}
+
+// GetCapabilityNames returns a list of all capability names
+func (c *Capabilities) GetCapabilityNames() []string {
+ names := make([]string, len(c.Capability))
+ for i, cap := range c.Capability {
+ names[i] = cap.Name
+ }
+ return names
+}
+
+// GetNetworkCapabilities returns a summary of network capabilities
+func (c *Capabilities) GetNetworkCapabilities() []string {
+ var capabilities []string
+
+ if c.HasDualModeNetwork() {
+ capabilities = append(capabilities, "Dual Mode")
+ }
+ if c.HasWSAPIProxy() {
+ capabilities = append(capabilities, "WSAPI Proxy")
+ }
+ if c.HasHostedWifiConfig() {
+ capabilities = append(capabilities, "Hosted WiFi Config")
+ }
+
+ return capabilities
+}
+
+// GetAudioCapabilities returns a summary of audio capabilities
+func (c *Capabilities) GetAudioCapabilities() []string {
+ var capabilities []string
+
+ if c.HasLRStereoCapability() {
+ capabilities = append(capabilities, "L/R Stereo")
+ }
+ if c.HasDSPMonoStereo() {
+ capabilities = append(capabilities, "DSP Mono/Stereo")
+ }
+
+ return capabilities
+}
+
+// GetSystemCapabilities returns a summary of system capabilities
+func (c *Capabilities) GetSystemCapabilities() []string {
+ var capabilities []string
+
+ if c.HasLightswitch() {
+ capabilities = append(capabilities, "Light Switch")
+ }
+ if c.HasClockDisplay() {
+ capabilities = append(capabilities, "Clock Display")
+ }
+ if c.HasBCOResetCapability() {
+ capabilities = append(capabilities, "BCO Reset")
+ }
+ if c.HasPowerSavingDisabled() {
+ capabilities = append(capabilities, "Power Saving Disabled")
+ }
+
+ return capabilities
+}
diff --git a/pkg/models/device_info_test.go b/pkg/models/device_info_test.go
new file mode 100644
index 0000000..fddbce1
--- /dev/null
+++ b/pkg/models/device_info_test.go
@@ -0,0 +1,419 @@
+package models
+
+import (
+ "encoding/xml"
+ "testing"
+ "time"
+)
+
+func TestName_UnmarshalXML(t *testing.T) {
+ xmlData := `Sound Machinechen`
+
+ var name Name
+ err := xml.Unmarshal([]byte(xmlData), &name)
+ if err != nil {
+ t.Fatalf("Failed to unmarshal XML: %v", err)
+ }
+
+ if name.Value != "Sound Machinechen" {
+ t.Errorf("Expected name 'Sound Machinechen', got '%s'", name.Value)
+ }
+
+ if name.GetName() != "Sound Machinechen" {
+ t.Errorf("Expected GetName() 'Sound Machinechen', got '%s'", name.GetName())
+ }
+
+ if name.String() != "Sound Machinechen" {
+ t.Errorf("Expected String() 'Sound Machinechen', got '%s'", name.String())
+ }
+
+ if name.IsEmpty() {
+ t.Error("Expected IsEmpty() to return false for non-empty name")
+ }
+}
+
+func TestName_EmptyName(t *testing.T) {
+ xmlData := ``
+
+ var name Name
+ err := xml.Unmarshal([]byte(xmlData), &name)
+ if err != nil {
+ t.Fatalf("Failed to unmarshal XML: %v", err)
+ }
+
+ if !name.IsEmpty() {
+ t.Error("Expected IsEmpty() to return true for empty name")
+ }
+
+ if name.GetName() != "" {
+ t.Errorf("Expected GetName() to return empty string, got '%s'", name.GetName())
+ }
+}
+
+func TestCapabilities_UnmarshalXML(t *testing.T) {
+ xmlData := `
+
+
+ true
+ true
+
+
+
+
+
+
+
+ false
+ false
+
+
+ true
+ false
+ true
+`
+
+ var capabilities Capabilities
+ err := xml.Unmarshal([]byte(xmlData), &capabilities)
+ if err != nil {
+ t.Fatalf("Failed to unmarshal XML: %v", err)
+ }
+
+ // Test basic fields
+ if capabilities.DeviceID != "A81B6A536A98" {
+ t.Errorf("Expected DeviceID 'A81B6A536A98', got '%s'", capabilities.DeviceID)
+ }
+
+ // Test boolean capabilities
+ if capabilities.HasLightswitch() {
+ t.Error("Expected HasLightswitch() to return false")
+ }
+
+ if capabilities.HasClockDisplay() {
+ t.Error("Expected HasClockDisplay() to return false")
+ }
+
+ if !capabilities.HasLRStereoCapability() {
+ t.Error("Expected HasLRStereoCapability() to return true")
+ }
+
+ if capabilities.HasBCOResetCapability() {
+ t.Error("Expected HasBCOResetCapability() to return false")
+ }
+
+ if !capabilities.HasPowerSavingDisabled() {
+ t.Error("Expected HasPowerSavingDisabled() to return true")
+ }
+
+ // Test network capabilities
+ if !capabilities.HasDualModeNetwork() {
+ t.Error("Expected HasDualModeNetwork() to return true")
+ }
+
+ if !capabilities.HasWSAPIProxy() {
+ t.Error("Expected HasWSAPIProxy() to return true")
+ }
+
+ // Test DSP capabilities
+ if capabilities.HasDSPMonoStereo() {
+ t.Error("Expected HasDSPMonoStereo() to return false")
+ }
+
+ // Test capability by name
+ if !capabilities.HasCapability("systemtimeout") {
+ t.Error("Expected to have systemtimeout capability")
+ }
+
+ if capabilities.HasCapability("nonexistent") {
+ t.Error("Expected to not have nonexistent capability")
+ }
+
+ // Test capability names
+ capNames := capabilities.GetCapabilityNames()
+ if len(capNames) != 2 {
+ t.Errorf("Expected 2 capability names, got %d", len(capNames))
+ }
+
+ // Test summaries
+ networkCaps := capabilities.GetNetworkCapabilities()
+ expectedNetworkCaps := []string{"Dual Mode", "WSAPI Proxy"}
+ if len(networkCaps) != len(expectedNetworkCaps) {
+ t.Errorf("Expected %d network capabilities, got %d", len(expectedNetworkCaps), len(networkCaps))
+ }
+
+ audioCaps := capabilities.GetAudioCapabilities()
+ expectedAudioCaps := []string{"L/R Stereo"}
+ if len(audioCaps) != len(expectedAudioCaps) {
+ t.Errorf("Expected %d audio capabilities, got %d", len(expectedAudioCaps), len(audioCaps))
+ }
+
+ systemCaps := capabilities.GetSystemCapabilities()
+ expectedSystemCaps := []string{"Power Saving Disabled"}
+ if len(systemCaps) != len(expectedSystemCaps) {
+ t.Errorf("Expected %d system capabilities, got %d", len(expectedSystemCaps), len(systemCaps))
+ }
+}
+
+func TestCapabilities_HostedWifiConfig(t *testing.T) {
+ xmlData := `
+
+
+ true
+ false
+
+
+
+
+ true
+ true
+`
+
+ var capabilities Capabilities
+ err := xml.Unmarshal([]byte(xmlData), &capabilities)
+ if err != nil {
+ t.Fatalf("Failed to unmarshal XML: %v", err)
+ }
+
+ if !capabilities.HasHostedWifiConfig() {
+ t.Error("Expected HasHostedWifiConfig() to return true")
+ }
+
+ if capabilities.GetHostedWifiPort() != "80" {
+ t.Errorf("Expected hosted wifi port '80', got '%s'", capabilities.GetHostedWifiPort())
+ }
+
+ if capabilities.GetHostedWifiHostedBy() != "BCO" {
+ t.Errorf("Expected hosted wifi hosted by 'BCO', got '%s'", capabilities.GetHostedWifiHostedBy())
+ }
+
+ if !capabilities.HasLightswitch() {
+ t.Error("Expected HasLightswitch() to return true")
+ }
+
+ if !capabilities.HasClockDisplay() {
+ t.Error("Expected HasClockDisplay() to return true")
+ }
+}
+
+func TestPresets_UnmarshalXML(t *testing.T) {
+ xmlData := `
+
+
+
+ My Playlist
+ https://i.scdn.co/image/ab67616d00001e025ff75c5d082fc50a3a74ad7b
+
+
+
+
+ Chill Music Collection
+ https://i.scdn.co/image/ab67616d0000b273e07c8adc6fb49168dc8b7a2f
+
+
+
+
+ Radio Station
+
+
+`
+
+ var presets Presets
+ err := xml.Unmarshal([]byte(xmlData), &presets)
+ if err != nil {
+ t.Fatalf("Failed to unmarshal XML: %v", err)
+ }
+
+ // Test basic structure
+ if presets.GetPresetCount() != 3 {
+ t.Errorf("Expected 3 presets, got %d", presets.GetPresetCount())
+ }
+
+ // Test first preset
+ preset1 := presets.GetPresetByID(1)
+ if preset1 == nil {
+ t.Fatal("Expected to find preset with ID 1")
+ }
+
+ if preset1.GetDisplayName() != "My Playlist" {
+ t.Errorf("Expected display name 'My Playlist', got '%s'", preset1.GetDisplayName())
+ }
+
+ if !preset1.IsSpotifyPreset() {
+ t.Error("Expected preset 1 to be a Spotify preset")
+ }
+
+ if preset1.GetSource() != "SPOTIFY" {
+ t.Errorf("Expected source 'SPOTIFY', got '%s'", preset1.GetSource())
+ }
+
+ if preset1.GetSourceAccount() != "user@example.com" {
+ t.Errorf("Expected source account 'user@example.com', got '%s'", preset1.GetSourceAccount())
+ }
+
+ if !preset1.HasTimestamps() {
+ t.Error("Expected preset 1 to have timestamps")
+ }
+
+ // Test timestamps
+ expectedCreated := time.Unix(1719128436, 0)
+ if !preset1.GetCreatedTime().Equal(expectedCreated) {
+ t.Errorf("Expected created time %v, got %v", expectedCreated, preset1.GetCreatedTime())
+ }
+
+ expectedUpdated := time.Unix(1728740382, 0)
+ if !preset1.GetUpdatedTime().Equal(expectedUpdated) {
+ t.Errorf("Expected updated time %v, got %v", expectedUpdated, preset1.GetUpdatedTime())
+ }
+
+ // Test third preset (TuneIn radio)
+ preset3 := presets.GetPresetByID(3)
+ if preset3 == nil {
+ t.Fatal("Expected to find preset with ID 3")
+ }
+
+ if preset3.IsSpotifyPreset() {
+ t.Error("Expected preset 3 to not be a Spotify preset")
+ }
+
+ if preset3.GetSource() != "TUNEIN" {
+ t.Errorf("Expected source 'TUNEIN', got '%s'", preset3.GetSource())
+ }
+
+ if preset3.HasTimestamps() {
+ t.Error("Expected preset 3 to not have timestamps")
+ }
+
+ // Test filtering methods
+ spotifyPresets := presets.GetSpotifyPresets()
+ if len(spotifyPresets) != 2 {
+ t.Errorf("Expected 2 Spotify presets, got %d", len(spotifyPresets))
+ }
+
+ tuneinPresets := presets.GetPresetsBySource("TUNEIN")
+ if len(tuneinPresets) != 1 {
+ t.Errorf("Expected 1 TuneIn preset, got %d", len(tuneinPresets))
+ }
+
+ // Test empty slots
+ emptySlots := presets.GetEmptyPresetSlots()
+ expectedEmpty := []int{4, 5, 6}
+ if len(emptySlots) != len(expectedEmpty) {
+ t.Errorf("Expected %d empty slots, got %d", len(expectedEmpty), len(emptySlots))
+ }
+
+ // Test used slots
+ usedSlots := presets.GetUsedPresetSlots()
+ expectedUsed := []int{1, 2, 3}
+ if len(usedSlots) != len(expectedUsed) {
+ t.Errorf("Expected %d used slots, got %d", len(expectedUsed), len(usedSlots))
+ }
+
+ // Test summary
+ summary := presets.GetPresetsSummary()
+ if summary["total"] != 3 {
+ t.Errorf("Expected total 3, got %d", summary["total"])
+ }
+ if summary["used"] != 3 {
+ t.Errorf("Expected used 3, got %d", summary["used"])
+ }
+ if summary["spotify"] != 2 {
+ t.Errorf("Expected spotify 2, got %d", summary["spotify"])
+ }
+ if summary["SPOTIFY"] != 2 {
+ t.Errorf("Expected SPOTIFY 2, got %d", summary["SPOTIFY"])
+ }
+ if summary["TUNEIN"] != 1 {
+ t.Errorf("Expected TUNEIN 1, got %d", summary["TUNEIN"])
+ }
+
+ // Test most recent preset
+ mostRecent := presets.GetMostRecentPreset()
+ if mostRecent == nil {
+ t.Fatal("Expected to find most recent preset")
+ }
+ if mostRecent.ID != 2 {
+ t.Errorf("Expected most recent preset ID 2, got %d", mostRecent.ID)
+ }
+
+ // Test oldest preset
+ oldest := presets.GetOldestPreset()
+ if oldest == nil {
+ t.Fatal("Expected to find oldest preset")
+ }
+ if oldest.ID != 2 {
+ t.Errorf("Expected oldest preset ID 2, got %d", oldest.ID)
+ }
+}
+
+func TestPresets_EmptyPresets(t *testing.T) {
+ xmlData := ``
+
+ var presets Presets
+ err := xml.Unmarshal([]byte(xmlData), &presets)
+ if err != nil {
+ t.Fatalf("Failed to unmarshal XML: %v", err)
+ }
+
+ if presets.HasPresets() {
+ t.Error("Expected HasPresets() to return false for empty presets")
+ }
+
+ if presets.GetPresetCount() != 0 {
+ t.Errorf("Expected preset count 0, got %d", presets.GetPresetCount())
+ }
+
+ emptySlots := presets.GetEmptyPresetSlots()
+ expectedSlots := []int{1, 2, 3, 4, 5, 6}
+ if len(emptySlots) != len(expectedSlots) {
+ t.Errorf("Expected %d empty slots, got %d", len(expectedSlots), len(emptySlots))
+ }
+
+ if presets.GetMostRecentPreset() != nil {
+ t.Error("Expected GetMostRecentPreset() to return nil for empty presets")
+ }
+
+ if presets.GetOldestPreset() != nil {
+ t.Error("Expected GetOldestPreset() to return nil for empty presets")
+ }
+}
+
+func TestPreset_EdgeCases(t *testing.T) {
+ // Test preset without ContentItem
+ emptyPreset := Preset{ID: 1}
+
+ if !emptyPreset.IsEmpty() {
+ t.Error("Expected IsEmpty() to return true for preset without ContentItem")
+ }
+
+ if emptyPreset.GetDisplayName() != "Preset 1" {
+ t.Errorf("Expected display name 'Preset 1', got '%s'", emptyPreset.GetDisplayName())
+ }
+
+ if emptyPreset.GetSource() != "" {
+ t.Errorf("Expected empty source, got '%s'", emptyPreset.GetSource())
+ }
+
+ if emptyPreset.GetArtworkURL() != "" {
+ t.Errorf("Expected empty artwork URL, got '%s'", emptyPreset.GetArtworkURL())
+ }
+
+ if emptyPreset.IsSpotifyPreset() {
+ t.Error("Expected IsSpotifyPreset() to return false for empty preset")
+ }
+
+ // Test preset with ContentItem but no artwork
+ presetNoArt := Preset{
+ ID: 2,
+ ContentItem: &ContentItem{
+ Source: "TUNEIN",
+ ItemName: "Test Station",
+ },
+ }
+
+ if presetNoArt.GetArtworkURL() != "" {
+ t.Errorf("Expected empty artwork URL, got '%s'", presetNoArt.GetArtworkURL())
+ }
+
+ if presetNoArt.GetDisplayName() != "Test Station" {
+ t.Errorf("Expected display name 'Test Station', got '%s'", presetNoArt.GetDisplayName())
+ }
+}
diff --git a/pkg/models/name.go b/pkg/models/name.go
new file mode 100644
index 0000000..d9562db
--- /dev/null
+++ b/pkg/models/name.go
@@ -0,0 +1,24 @@
+package models
+
+import "encoding/xml"
+
+// Name represents the response from /name endpoint
+type Name struct {
+ XMLName xml.Name `xml:"name"`
+ Value string `xml:",chardata"`
+}
+
+// GetName returns the device name
+func (n *Name) GetName() string {
+ return n.Value
+}
+
+// IsEmpty returns true if the name is empty
+func (n *Name) IsEmpty() bool {
+ return n.Value == ""
+}
+
+// String returns the device name as a string
+func (n *Name) String() string {
+ return n.Value
+}
diff --git a/pkg/models/presets.go b/pkg/models/presets.go
new file mode 100644
index 0000000..7a25e66
--- /dev/null
+++ b/pkg/models/presets.go
@@ -0,0 +1,244 @@
+package models
+
+import (
+ "encoding/xml"
+ "strconv"
+ "time"
+)
+
+// Presets represents the response from /presets endpoint
+type Presets struct {
+ XMLName xml.Name `xml:"presets"`
+ Preset []Preset `xml:"preset"`
+}
+
+// Preset represents an individual preset
+type Preset struct {
+ ID int `xml:"id,attr"`
+ CreatedOn *int64 `xml:"createdOn,attr,omitempty"`
+ UpdatedOn *int64 `xml:"updatedOn,attr,omitempty"`
+ ContentItem *ContentItem `xml:"ContentItem,omitempty"`
+}
+
+// GetCreatedTime returns the creation time as a time.Time
+func (p *Preset) GetCreatedTime() time.Time {
+ if p.CreatedOn != nil {
+ return time.Unix(*p.CreatedOn, 0)
+ }
+ return time.Time{}
+}
+
+// GetUpdatedTime returns the last updated time as a time.Time
+func (p *Preset) GetUpdatedTime() time.Time {
+ if p.UpdatedOn != nil {
+ return time.Unix(*p.UpdatedOn, 0)
+ }
+ return time.Time{}
+}
+
+// HasTimestamps returns true if the preset has creation/update timestamps
+func (p *Preset) HasTimestamps() bool {
+ return p.CreatedOn != nil || p.UpdatedOn != nil
+}
+
+// GetDisplayName returns the best available display name for the preset
+func (p *Preset) GetDisplayName() string {
+ if p.ContentItem != nil && p.ContentItem.ItemName != "" {
+ return p.ContentItem.ItemName
+ }
+ return "Preset " + strconv.Itoa(p.ID)
+}
+
+// GetArtworkURL returns the artwork URL if available
+func (p *Preset) GetArtworkURL() string {
+ if p.ContentItem != nil && p.ContentItem.ContainerArt != "" {
+ return p.ContentItem.ContainerArt
+ }
+ return ""
+}
+
+// IsSpotifyPreset returns true if this is a Spotify preset
+func (p *Preset) IsSpotifyPreset() bool {
+ return p.ContentItem != nil && p.ContentItem.Source == "SPOTIFY"
+}
+
+// IsEmpty returns true if the preset has no content
+func (p *Preset) IsEmpty() bool {
+ return p.ContentItem == nil
+}
+
+// GetSource returns the source of the preset content
+func (p *Preset) GetSource() string {
+ if p.ContentItem != nil {
+ return p.ContentItem.Source
+ }
+ return ""
+}
+
+// GetSourceAccount returns the source account of the preset content
+func (p *Preset) GetSourceAccount() string {
+ if p.ContentItem != nil {
+ return p.ContentItem.SourceAccount
+ }
+ return ""
+}
+
+// GetContentType returns the content type of the preset
+func (p *Preset) GetContentType() string {
+ if p.ContentItem != nil {
+ return p.ContentItem.Type
+ }
+ return ""
+}
+
+// GetLocation returns the content location/URL
+func (p *Preset) GetLocation() string {
+ if p.ContentItem != nil {
+ return p.ContentItem.Location
+ }
+ return ""
+}
+
+// IsPresetable returns true if the content can be saved as a preset
+func (p *Preset) IsPresetable() bool {
+ return p.ContentItem != nil && p.ContentItem.IsPresetable
+}
+
+// GetPresetCount returns the total number of presets
+func (ps *Presets) GetPresetCount() int {
+ return len(ps.Preset)
+}
+
+// GetPresetByID returns a preset by its ID
+func (ps *Presets) GetPresetByID(id int) *Preset {
+ for _, preset := range ps.Preset {
+ if preset.ID == id {
+ return &preset
+ }
+ }
+ return nil
+}
+
+// GetSpotifyPresets returns all Spotify presets
+func (ps *Presets) GetSpotifyPresets() []Preset {
+ var spotify []Preset
+ for _, preset := range ps.Preset {
+ if preset.IsSpotifyPreset() {
+ spotify = append(spotify, preset)
+ }
+ }
+ return spotify
+}
+
+// GetPresetsBySource returns presets filtered by source
+func (ps *Presets) GetPresetsBySource(source string) []Preset {
+ var filtered []Preset
+ for _, preset := range ps.Preset {
+ if preset.GetSource() == source {
+ filtered = append(filtered, preset)
+ }
+ }
+ return filtered
+}
+
+// GetEmptyPresetSlots returns preset IDs that are empty (1-6)
+func (ps *Presets) GetEmptyPresetSlots() []int {
+ var empty []int
+ used := make(map[int]bool)
+
+ // Mark used slots
+ for _, preset := range ps.Preset {
+ if !preset.IsEmpty() {
+ used[preset.ID] = true
+ }
+ }
+
+ // Find empty slots (1-6 are typical preset slots)
+ for i := 1; i <= 6; i++ {
+ if !used[i] {
+ empty = append(empty, i)
+ }
+ }
+
+ return empty
+}
+
+// HasPresets returns true if there are any presets configured
+func (ps *Presets) HasPresets() bool {
+ for _, preset := range ps.Preset {
+ if !preset.IsEmpty() {
+ return true
+ }
+ }
+ return false
+}
+
+// GetUsedPresetSlots returns preset IDs that have content
+func (ps *Presets) GetUsedPresetSlots() []int {
+ var used []int
+ for _, preset := range ps.Preset {
+ if !preset.IsEmpty() {
+ used = append(used, preset.ID)
+ }
+ }
+ return used
+}
+
+// GetMostRecentPreset returns the most recently updated preset
+func (ps *Presets) GetMostRecentPreset() *Preset {
+ var mostRecent *Preset
+ var latestTime int64
+
+ for _, preset := range ps.Preset {
+ if preset.UpdatedOn != nil && *preset.UpdatedOn > latestTime {
+ latestTime = *preset.UpdatedOn
+ mostRecent = &preset
+ } else if preset.CreatedOn != nil && preset.UpdatedOn == nil && *preset.CreatedOn > latestTime {
+ latestTime = *preset.CreatedOn
+ mostRecent = &preset
+ }
+ }
+
+ return mostRecent
+}
+
+// GetOldestPreset returns the oldest preset
+func (ps *Presets) GetOldestPreset() *Preset {
+ var oldest *Preset
+ var earliestTime int64 = 9223372036854775807 // max int64
+
+ for _, preset := range ps.Preset {
+ if preset.CreatedOn != nil && *preset.CreatedOn < earliestTime {
+ earliestTime = *preset.CreatedOn
+ oldest = &preset
+ }
+ }
+
+ return oldest
+}
+
+// GetPresetsSummary returns a summary of preset usage
+func (ps *Presets) GetPresetsSummary() map[string]int {
+ summary := map[string]int{
+ "total": ps.GetPresetCount(),
+ "used": len(ps.GetUsedPresetSlots()),
+ "empty": len(ps.GetEmptyPresetSlots()),
+ "spotify": len(ps.GetSpotifyPresets()),
+ }
+
+ // Count by source
+ sources := make(map[string]int)
+ for _, preset := range ps.Preset {
+ if !preset.IsEmpty() {
+ source := preset.GetSource()
+ sources[source]++
+ }
+ }
+
+ // Add source counts to summary
+ for source, count := range sources {
+ summary[source] = count
+ }
+
+ return summary
+}