diff --git a/docs/README.md b/docs/README.md index 585ac1f..a7c17ca 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ Welcome to the documentation for the Bose SoundTouch Toolkit. This toolkit helps - [HTTPS Setup](guides/HTTPS-SETUP.md) - [Deployment Guide](guides/DEPLOYMENT.md) - [Raspberry Pi Setup](guides/RASPBERRY-PI.md) +- [MAC Address Mapping](guides/MAC-ADDRESS-MAPPING.md) - [Troubleshooting](guides/TROUBLESHOOTING.md) ### Technical Reference diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 9b1dd6e..7061ab8 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -9,6 +9,7 @@ * [Getting Started](guides/GETTING-STARTED.md) * [SoundTouch Service](guides/SOUNDTOUCH-SERVICE.md) * [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md) +* [MAC Address Mapping](guides/MAC-ADDRESS-MAPPING.md) * [HTTPS Setup](guides/HTTPS-SETUP.md) * [Deployment](guides/DEPLOYMENT.md) * [Raspberry Pi Guide](guides/RASPBERRY-PI.md) diff --git a/docs/guides/MAC-ADDRESS-MAPPING.md b/docs/guides/MAC-ADDRESS-MAPPING.md new file mode 100644 index 0000000..0c9c837 --- /dev/null +++ b/docs/guides/MAC-ADDRESS-MAPPING.md @@ -0,0 +1,218 @@ +# MAC Address to Serial Number Mapping + +**Understanding and troubleshooting device identification in SoundTouch service** + +This guide explains how the SoundTouch service handles device identification through MAC address to serial number mapping, and how to troubleshoot related issues. + +## 📋 **Overview** + +The SoundTouch service uses two different identifiers for devices: + +- **MAC Address** (`A81B6A536A98`) - Used in HTTP API requests and UPnP discovery +- **Serial Number** (`I6332527703739342000020`) - Used for internal file storage + +The service automatically maps between these identifiers so that API requests using MAC addresses can access files stored using serial numbers. + +## 🔍 **How It Works** + +### Request Flow +``` +1. HTTP Request: GET /streaming/account/3230304/device/A81B6A536A98/presets +2. MAC Resolution: A81B6A536A98 → I6332527703739342000020 +3. File Access: accounts/3230304/devices/I6332527703739342000020/Presets.xml +``` + +### UPnP Discovery Integration +The service extracts MAC addresses from UPnP device descriptions: + +```xml + + + + Sound Machinery + SoundTouch 10 + A81B6A536A98 + + +``` + +## ⚙ïļ **Automatic Setup** + +The mapping is created automatically when the service starts: + +1. **Directory Scan**: Service scans `data/accounts/{account}/devices/{serial}/` +2. **DeviceInfo.xml**: Reads MAC address from each device's info file +3. **Mapping Creation**: Creates MAC → Serial mapping in memory +4. **Normalization**: Handles different MAC address formats automatically + +## 🛠ïļ **Supported MAC Address Formats** + +The service handles all common MAC address formats automatically: + +| Format | Example | Status | +|-------------|---------------------|-------------| +| Standard | `A81B6A536A98` | ✅ Supported | +| Lowercase | `a81b6a536a98` | ✅ Supported | +| With Colons | `A8:1B:6A:53:6A:98` | ✅ Supported | +| With Dashes | `A8-1B-6A-53-6A-98` | ✅ Supported | +| Mixed Case | `a81B6a536A98` | ✅ Supported | +| With Spaces | ` A81B6A536A98 ` | ✅ Supported | + +## 🔧 **Troubleshooting** + +### Problem: API requests fail with "file not found" errors + +**Symptoms:** +``` +GET /streaming/account/3230304/device/A81B6A536A98/presets +→ 500 Internal Server Error +→ Log: "open .../devices/A81B6A536A98/Presets.xml: no such file or directory" +``` + +**Diagnosis:** +1. Check if mapping exists: + ```bash + # Look for device directory + ls data/accounts/3230304/devices/ + # Should show serial numbers like: I6332527703739342000020 + ``` + +2. Check DeviceInfo.xml: + ```bash + cat data/accounts/3230304/devices/I6332527703739342000020/DeviceInfo.xml + # Look for field + ``` + +**Solutions:** + +#### Solution 1: Restart the Service +The mapping is created at startup. Simply restart: +```bash +sudo systemctl restart soundtouch-service +``` + +#### Solution 2: Check DeviceInfo.xml Format +Ensure the MAC address is present: +```xml + + + A81B6A536A98 + 192.168.178.35 + + +``` + +#### Solution 3: Manual Device Addition +If the device was added manually, ensure proper structure: +```bash +# Create device directory using serial number +mkdir -p data/accounts/3230304/devices/I6332527703739342000020 + +# Create DeviceInfo.xml with MAC address +cat > data/accounts/3230304/devices/I6332527703739342000020/DeviceInfo.xml << EOF + + + My SoundTouch Device + + A81B6A536A98 + 192.168.1.100 + + +EOF +``` + +### Problem: UPnP discovery not creating mappings + +**Check UPnP accessibility:** +```bash +# Test UPnP endpoint directly +curl http://192.168.1.100:8091/XD/BO5EBO5E-F00D-F00D-FEED-A81B6A536A98.xml + +# Should return XML with field +``` + +**Enable debug logging:** +```bash +# Check service logs for UPnP activity +journalctl -u soundtouch-service -f | grep UPnP +``` + +### Problem: Case or format mismatches + +This should be handled automatically, but you can verify: + +**Test different formats:** +```bash +# All of these should work the same: +curl http://localhost:8000/streaming/account/3230304/device/A81B6A536A98/presets +curl http://localhost:8000/streaming/account/3230304/device/a81b6a536a98/presets +curl http://localhost:8000/streaming/account/3230304/device/A8:1B:6A:53:6A:98/presets +``` + +## 📊 **Monitoring and Diagnostics** + +### Check Current Mappings +The service logs mapping creation at startup: +```bash +journalctl -u soundtouch-service | grep "MAC.*serial" +``` + +### Verify File Structure +Ensure proper directory organization: +``` +data/ +└── accounts/ + └── 3230304/ + └── devices/ + └── I6332527703739342000020/ # Serial number directory + ├── DeviceInfo.xml # Contains MAC address + ├── Presets.xml + └── Sources.xml +``` + +## 🔗 **Related Documentation** + +- [Device Initial Setup](DEVICE-INITIAL-SETUP.md) - Setting up new devices +- [Troubleshooting Guide](TROUBLESHOOTING.md) - General troubleshooting steps +- [SoundTouch Service](SOUNDTOUCH-SERVICE.md) - Service configuration and management + +## 🏗ïļ **Technical Implementation** + +For developers interested in the technical details: + +### Normalization Algorithm +```go +// MAC addresses are normalized by: +// 1. Removing spaces, colons, and dashes +// 2. Converting to uppercase +// Examples: +// "a8:1b:6a:53:6a:98" → "A81B6A536A98" +// "A8-1B-6A-53-6A-98" → "A81B6A536A98" +``` + +### Lookup Process +```go +// 1. Try exact match first +// 2. If not found, try normalized version +// 3. Return serial number for file access +``` + +### Performance +- **Lookup Time**: O(1) - Hash map lookup +- **Memory Usage**: ~40 bytes per device mapping +- **Initialization**: Scans all devices once at startup + +## 📝 **Best Practices** + +1. **Use Discovery**: Let UPnP discovery create mappings automatically +2. **Consistent Format**: Store MAC addresses consistently in DeviceInfo.xml +3. **Service Restart**: Restart service after manual device additions +4. **Monitoring**: Check logs for mapping creation during startup +5. **Backup**: Keep DeviceInfo.xml files backed up + +## ⚠ïļ **Known Limitations** + +- Mappings are created only at service startup +- Manual device additions require service restart +- MAC addresses must be present in DeviceInfo.xml +- No automatic cleanup of stale mappings (restart required) diff --git a/docs/guides/TROUBLESHOOTING.md b/docs/guides/TROUBLESHOOTING.md index c40b98b..86115a1 100644 --- a/docs/guides/TROUBLESHOOTING.md +++ b/docs/guides/TROUBLESHOOTING.md @@ -817,6 +817,42 @@ Use this checklist to systematically troubleshoot issues: --- +## 🆔 **Device Identification & Mapping Issues** + +### ❌ "File not found" errors with MAC addresses + +**Symptoms:** +``` +GET /streaming/account/3230304/device/A81B6A536A98/presets +→ 500 Internal Server Error +→ Log: "open .../devices/A81B6A536A98/Presets.xml: no such file or directory" +``` + +**Cause:** The service uses MAC addresses in API requests but stores files using device serial numbers. A mapping system resolves MAC addresses to serial numbers automatically. + +**Quick Solutions:** + +1. **Restart the service** (mappings are created at startup): +```bash +sudo systemctl restart soundtouch-service +``` + +2. **Check device directory structure**: +```bash +# Files should be stored by serial number, not MAC +ls data/accounts/3230304/devices/ +# Should show: I6332527703739342000020/ (not A81B6A536A98/) +``` + +3. **Verify DeviceInfo.xml contains MAC address**: +```bash +cat data/accounts/3230304/devices/*/DeviceInfo.xml | grep macAddress +``` + +**For detailed diagnosis and solutions**, see: [**MAC Address Mapping Guide**](MAC-ADDRESS-MAPPING.md) + +--- + ## 🛟 **Getting More Help** ### Information to Gather diff --git a/pkg/discovery/upnp.go b/pkg/discovery/upnp.go index c781e66..79a2e3e 100644 --- a/pkg/discovery/upnp.go +++ b/pkg/discovery/upnp.go @@ -379,7 +379,7 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro log.Printf("UPnP: Successfully parsed device from location: %s at %s:%d", device.Name, device.Host, device.Port) // Try to get more device info from the location URL - if err := d.enrichDeviceInfo(device, location); err != nil { + if err := d.EnrichDeviceInfo(device, location); err != nil { log.Printf("UPnP: Could not enrich device info from location '%s': %v", location, err) // Don't fail if we can't get additional info // The basic info from URL parsing should be sufficient @@ -422,8 +422,8 @@ func (d *Service) parseLocationURL(location, usn string) (*models.DiscoveredDevi return device, nil } -// enrichDeviceInfo tries to get additional device information from the device description -func (d *Service) enrichDeviceInfo(device *models.DiscoveredDevice, location string) error { +// EnrichDeviceInfo tries to get additional device information from the device description +func (d *Service) EnrichDeviceInfo(device *models.DiscoveredDevice, location string) error { log.Printf("UPnP: Attempting to enrich device info by fetching %s", location) resp, err := d.httpClient.Get(location) diff --git a/pkg/discovery/upnp_enrich_test.go b/pkg/discovery/upnp_enrich_test.go index 8c48a88..1c3c1b6 100644 --- a/pkg/discovery/upnp_enrich_test.go +++ b/pkg/discovery/upnp_enrich_test.go @@ -35,7 +35,7 @@ func TestEnrichDeviceInfo(t *testing.T) { service := NewService(1 * time.Second) service.httpClient = server.Client() - err := service.enrichDeviceInfo(device, server.URL) + err := service.EnrichDeviceInfo(device, server.URL) if err != nil { t.Fatalf("enrichDeviceInfo failed: %v", err) diff --git a/pkg/discovery/upnp_mac_test.go b/pkg/discovery/upnp_mac_test.go new file mode 100644 index 0000000..f9d368a --- /dev/null +++ b/pkg/discovery/upnp_mac_test.go @@ -0,0 +1,319 @@ +package discovery + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/models" +) + +func TestUPnP_EnrichDeviceInfo_RealDeviceXML(t *testing.T) { + // This tests the exact UPnP XML format provided by the user + realDeviceXML := ` + + + 1 + 0 + + + urn:schemas-upnp-org:device:MediaRenderer:1 + Sound Machinechen + QPlay:2 + Bose Corporation + http://www.bose.com + SoundTouch 10 + + Bose SoundTouch Wireless Streaming Audio Device + http://www.bose.com + A81B6A536A98 + uuid:BO5EBO5E-F00D-F00D-FEED-A81B6A536A98 + + + urn:schemas-upnp-org:service:AVTransport:1 + urn:upnp-org:serviceId:AVTransport + /Xml/AVTransport3.xml + /AVTransport/Control + /AVTransport/Event + + + urn:schemas-upnp-org:service:ConnectionManager:1 + urn:upnp-org:serviceId:ConnectionManager + /Xml/ConnectionManager3.xml + /ConnectionManager/Control + /ConnectionManager/Event + + + urn:schemas-upnp-org:service:RenderingControl:1 + urn:upnp-org:serviceId:RenderingControl + /Xml/RenderingControl3.xml + /RenderingControl/Control + /RenderingControl/Event + + + urn:schemas-tencent-com:service:QPlay:2 + urn:tencent-com:serviceId:QPlay + /QPlay/Control + /QPlay/Event + /Xml/QPlay.xml + + + +` + + // Create a test server that serves the UPnP XML + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/xml; charset=utf-8") + fmt.Fprint(w, realDeviceXML) + })) + defer server.Close() + + // Create a discovered device to enrich + device := &models.DiscoveredDevice{ + Host: "192.168.178.35", + Port: 8091, + Name: "Initial Device Name", + } + + // Create discovery service and enrich the device + service := NewService(5 * time.Second) + err := service.EnrichDeviceInfo(device, server.URL) + + if err != nil { + t.Fatalf("enrichDeviceInfo failed: %v", err) + } + + // Verify that the MAC address was extracted correctly from serialNumber + expectedMAC := "A81B6A536A98" + if device.UPnPSerial != expectedMAC { + t.Errorf("Expected UPnPSerial '%s', got '%s'", expectedMAC, device.UPnPSerial) + } + + // Verify other enriched fields + expectedName := "Sound Machinechen" + if device.Name != expectedName { + t.Errorf("Expected Name '%s', got '%s'", expectedName, device.Name) + } + + expectedModel := "SoundTouch 10" + if device.ModelID != expectedModel { + t.Errorf("Expected ModelID '%s', got '%s'", expectedModel, device.ModelID) + } + + t.Logf("✓ Successfully extracted MAC address '%s' from UPnP serialNumber field", device.UPnPSerial) + t.Logf("✓ Device name: '%s'", device.Name) + t.Logf("✓ Device model: '%s'", device.ModelID) +} + +func TestUPnP_MACAddressDiscovery_Integration(t *testing.T) { + // Test various MAC address formats that might appear in serialNumber + testCases := []struct { + name string + serialNumberInXML string + expectedUPnPSerial string + description string + }{ + { + name: "StandardMAC", + serialNumberInXML: "A81B6A536A98", + expectedUPnPSerial: "A81B6A536A98", + description: "Standard MAC address format without separators", + }, + { + name: "MACWithColons", + serialNumberInXML: "A8:1B:6A:53:6A:98", + expectedUPnPSerial: "A8:1B:6A:53:6A:98", + description: "MAC address with colon separators", + }, + { + name: "MACWithDashes", + serialNumberInXML: "A8-1B-6A-53-6A-98", + expectedUPnPSerial: "A8-1B-6A-53-6A-98", + description: "MAC address with dash separators", + }, + { + name: "LowercaseMAC", + serialNumberInXML: "a81b6a536a98", + expectedUPnPSerial: "a81b6a536a98", + description: "Lowercase MAC address", + }, + { + name: "MixedCaseMAC", + serialNumberInXML: "a81B6a536A98", + expectedUPnPSerial: "a81B6a536A98", + description: "Mixed case MAC address", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Create UPnP XML with the specific serialNumber format + xmlTemplate := ` + + + urn:schemas-upnp-org:device:MediaRenderer:1 + Test Device + Bose Corporation + SoundTouch 10 + %s + uuid:BO5EBO5E-F00D-F00D-FEED-TEST + +` + + deviceXML := fmt.Sprintf(xmlTemplate, tc.serialNumberInXML) + + // Create test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/xml") + fmt.Fprint(w, deviceXML) + })) + defer server.Close() + + // Create and enrich device + device := &models.DiscoveredDevice{ + Host: "192.168.1.100", + Port: 8090, + } + + service := NewService(5 * time.Second) + err := service.EnrichDeviceInfo(device, server.URL) + + if err != nil { + t.Errorf("%s: enrichDeviceInfo failed: %v", tc.description, err) + return + } + + if device.UPnPSerial != tc.expectedUPnPSerial { + t.Errorf("%s: Expected UPnPSerial '%s', got '%s'", + tc.description, tc.expectedUPnPSerial, device.UPnPSerial) + } else { + t.Logf("✓ %s: Successfully extracted '%s'", tc.description, device.UPnPSerial) + } + }) + } +} + +func TestUPnP_URLPattern_Realistic(t *testing.T) { + // Test the exact URL pattern mentioned: + // http://192.168.178.35:8091/XD/BO5EBO5E-F00D-F00D-FEED-A81B6A536A98.xml + + realDeviceXML := ` + + + urn:schemas-upnp-org:device:MediaRenderer:1 + Sound Machinechen + Bose Corporation + SoundTouch 10 + A81B6A536A98 + uuid:BO5EBO5E-F00D-F00D-FEED-A81B6A536A98 + +` + + // Create server that responds to the specific path + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/XD/BO5EBO5E-F00D-F00D-FEED-A81B6A536A98.xml" { + w.Header().Set("Content-Type", "text/xml") + fmt.Fprint(w, realDeviceXML) + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + // Test enrichment using the realistic URL path + device := &models.DiscoveredDevice{ + Host: "192.168.178.35", + Port: 8091, + Name: "Initial Name", + } + + service := NewService(5 * time.Second) + locationURL := server.URL + "/XD/BO5EBO5E-F00D-F00D-FEED-A81B6A536A98.xml" + err := service.EnrichDeviceInfo(device, locationURL) + + if err != nil { + t.Fatalf("enrichDeviceInfo failed for realistic URL: %v", err) + } + + // Verify MAC address extraction + expectedMAC := "A81B6A536A98" + if device.UPnPSerial != expectedMAC { + t.Errorf("Expected MAC '%s', got '%s'", expectedMAC, device.UPnPSerial) + } + + // Note: The MAC address in the URL and in the XML serialNumber should match + if device.UPnPSerial == expectedMAC { + t.Logf("✓ MAC address '%s' extracted from UPnP XML matches expected value", device.UPnPSerial) + t.Logf("✓ This MAC can now be used for datastore mapping") + t.Logf("✓ Request URL pattern: GET /streaming/account/{account}/device/%s/presets", device.UPnPSerial) + } +} + +func TestUPnP_ErrorHandling(t *testing.T) { + service := NewService(5 * time.Second) + device := &models.DiscoveredDevice{ + Host: "192.168.1.100", + Name: "Test Device", + } + + t.Run("InvalidXML", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/xml") + fmt.Fprint(w, "invalid xml content") + })) + defer server.Close() + + err := service.EnrichDeviceInfo(device, server.URL) + if err == nil { + t.Error("Expected error for invalid XML, got nil") + } else { + t.Logf("✓ Correctly handled invalid XML: %v", err) + } + }) + + t.Run("HTTPError", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + err := service.EnrichDeviceInfo(device, server.URL) + if err == nil { + t.Error("Expected error for HTTP 500, got nil") + } else { + t.Logf("✓ Correctly handled HTTP error: %v", err) + } + }) + + t.Run("MissingSerialNumber", func(t *testing.T) { + xmlWithoutSerial := ` + + + Test Device + Test Model + +` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/xml") + fmt.Fprint(w, xmlWithoutSerial) + })) + defer server.Close() + + deviceCopy := *device // Make a copy to avoid modifying the original + err := service.EnrichDeviceInfo(&deviceCopy, server.URL) + + // Should not error, but UPnPSerial should be empty + if err != nil { + t.Errorf("Unexpected error for missing serialNumber: %v", err) + } + + if deviceCopy.UPnPSerial != "" { + t.Errorf("Expected empty UPnPSerial, got '%s'", deviceCopy.UPnPSerial) + } else { + t.Logf("✓ Correctly handled missing serialNumber (empty UPnPSerial)") + } + }) +} diff --git a/pkg/service/datastore/case_sensitivity_test.go b/pkg/service/datastore/case_sensitivity_test.go new file mode 100644 index 0000000..6c8368d --- /dev/null +++ b/pkg/service/datastore/case_sensitivity_test.go @@ -0,0 +1,296 @@ +package datastore + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/service/constants" +) + +func TestMacAddressCaseSensitivity(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "case-sensitivity-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + accountID := "3230304" + serialNumber := "I6332527703739342000020" + + // Test scenarios that could occur in production + testCases := []struct { + name string + macInDeviceInfo string + macInRequest string + expectedToWork bool + description string + }{ + { + name: "ExactMatch", + macInDeviceInfo: "A81B6A536A98", + macInRequest: "A81B6A536A98", + expectedToWork: true, + description: "Exact case match should work", + }, + { + name: "DeviceInfoUpperRequestLower", + macInDeviceInfo: "A81B6A536A98", + macInRequest: "a81b6a536a98", + expectedToWork: true, + description: "DeviceInfo has uppercase, request has lowercase (should work with normalization)", + }, + { + name: "DeviceInfoLowerRequestUpper", + macInDeviceInfo: "a81b6a536a98", + macInRequest: "A81B6A536A98", + expectedToWork: true, + description: "DeviceInfo has lowercase, request has uppercase (should work with normalization)", + }, + { + name: "MixedCaseInDeviceInfo", + macInDeviceInfo: "a81B6a536A98", + macInRequest: "A81B6A536A98", + expectedToWork: true, + description: "Mixed case in DeviceInfo vs uppercase request (should work with normalization)", + }, + { + name: "WithColonsInDeviceInfo", + macInDeviceInfo: "A8:1B:6A:53:6A:98", + macInRequest: "A81B6A536A98", + expectedToWork: true, + description: "DeviceInfo has colons, request without (should work with normalization)", + }, + { + name: "WithDashesInDeviceInfo", + macInDeviceInfo: "A8-1B-6A-53-6A-98", + macInRequest: "A81B6A536A98", + expectedToWork: true, + description: "DeviceInfo has dashes, request without (should work with normalization)", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Create separate directory for this test case + testDir := filepath.Join(tmpDir, tc.name) + deviceDir := filepath.Join(testDir, "accounts", accountID, "devices", serialNumber) + if err := os.MkdirAll(deviceDir, 0755); err != nil { + t.Fatalf("failed to create device dir: %v", err) + } + + // Create DeviceInfo.xml with specific MAC format + deviceInfoXML := ` + + Test Device + + + SCM + 4.8.1 + ` + serialNumber + ` + + + + ` + tc.macInDeviceInfo + ` + 192.168.1.100 + +` + if err := os.WriteFile(filepath.Join(deviceDir, constants.DeviceInfoFile), []byte(deviceInfoXML), 0644); err != nil { + t.Fatalf("failed to write DeviceInfo.xml: %v", err) + } + + // Create Presets.xml + presetsXML := `test` + if err := os.WriteFile(filepath.Join(deviceDir, constants.PresetsFile), []byte(presetsXML), 0644); err != nil { + t.Fatalf("failed to write Presets.xml: %v", err) + } + + // Initialize datastore + ds := NewDataStore(testDir) + if err := ds.Initialize(); err != nil { + t.Fatalf("failed to initialize datastore: %v", err) + } + + // Check mapping + ds.idMutex.RLock() + mappedSerial, hasMappingForRequest := ds.macToSerial[tc.macInRequest] + mappedSerialFromDeviceInfo, hasMappingForDeviceInfo := ds.macToSerial[tc.macInDeviceInfo] + ds.idMutex.RUnlock() + + t.Logf("%s:", tc.description) + t.Logf(" MAC in DeviceInfo.xml: '%s'", tc.macInDeviceInfo) + t.Logf(" MAC in request: '%s'", tc.macInRequest) + t.Logf(" Mapping exists for request MAC: %v", hasMappingForRequest) + t.Logf(" Mapping exists for DeviceInfo MAC: %v", hasMappingForDeviceInfo) + + if hasMappingForRequest { + t.Logf(" Request MAC '%s' maps to serial: '%s'", tc.macInRequest, mappedSerial) + } + if hasMappingForDeviceInfo { + t.Logf(" DeviceInfo MAC '%s' maps to serial: '%s'", tc.macInDeviceInfo, mappedSerialFromDeviceInfo) + } + + // Try GetPresets + _, err := ds.GetPresets(accountID, tc.macInRequest) + worked := err == nil + + if tc.expectedToWork && !worked { + t.Errorf("Expected success but got error: %v", err) + } else if !tc.expectedToWork && worked { + t.Errorf("Expected failure but got success") + } else if worked { + t.Logf(" ✓ Successfully resolved MAC '%s'", tc.macInRequest) + } else { + t.Logf(" ✓ Correctly failed to resolve MAC '%s'", tc.macInRequest) + } + }) + } +} + +// TestProductionScenarioSimulation simulates the exact issue described +func TestProductionScenarioSimulation(t *testing.T) { + // This test specifically simulates the production scenario where: + // Request: GET /streaming/account/3230304/device/A81B6A536A98/presets + // File exists at: /var/lib/soundtouch-service/accounts/3230304/devices/I6332527703739342000020/Presets.xml + + tmpDir, err := os.MkdirTemp("", "production-scenario") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + accountID := "3230304" + serialNumber := "I6332527703739342000020" + requestMAC := "A81B6A536A98" + + // Test different MAC address formats that could be in DeviceInfo.xml + possibleMACFormats := []string{ + "A81B6A536A98", // Exact match + "a81b6a536a98", // All lowercase + "A81b6a536A98", // Mixed case + "A8:1B:6A:53:6A:98", // With colons + "A8-1B-6A-53-6A-98", // With dashes + "a8:1b:6a:53:6a:98", // Lowercase with colons + "a8-1b-6a-53-6a-98", // Lowercase with dashes + } + + t.Logf("Production scenario simulation:") + t.Logf("Request URL: GET /streaming/account/%s/device/%s/presets", accountID, requestMAC) + t.Logf("Expected file location: accounts/%s/devices/%s/Presets.xml", accountID, serialNumber) + t.Logf("") + + for i, macFormat := range possibleMACFormats { + t.Run(fmt.Sprintf("MACFormat_%d", i), func(t *testing.T) { + // Create fresh directory for this test + testDir := filepath.Join(tmpDir, fmt.Sprintf("test_%d", i)) + deviceDir := filepath.Join(testDir, "accounts", accountID, "devices", serialNumber) + if err := os.MkdirAll(deviceDir, 0755); err != nil { + t.Fatalf("failed to create device dir: %v", err) + } + + // Create DeviceInfo.xml with this MAC format + deviceInfoXML := ` + + SoundTouch Device + + + SCM + 4.8.1 + ` + serialNumber + ` + + + + ` + macFormat + ` + 192.168.1.100 + +` + if err := os.WriteFile(filepath.Join(deviceDir, constants.DeviceInfoFile), []byte(deviceInfoXML), 0644); err != nil { + t.Fatalf("failed to write DeviceInfo.xml: %v", err) + } + + // Create the target file that should be found + presetsXML := `test` + if err := os.WriteFile(filepath.Join(deviceDir, constants.PresetsFile), []byte(presetsXML), 0644); err != nil { + t.Fatalf("failed to write Presets.xml: %v", err) + } + + // Initialize datastore + ds := NewDataStore(testDir) + if err := ds.Initialize(); err != nil { + t.Fatalf("failed to initialize datastore: %v", err) + } + + // Try to access using the request MAC + presets, err := ds.GetPresets(accountID, requestMAC) + + if err == nil { + t.Logf("✓ SUCCESS: MAC format '%s' in DeviceInfo allows request with '%s' to work (%d presets found)", + macFormat, requestMAC, len(presets)) + } else { + t.Logf("✗ FAILED: MAC format '%s' in DeviceInfo does not allow request with '%s' (error: %v)", + macFormat, requestMAC, err) + } + + // Check what actually got mapped + ds.idMutex.RLock() + for mac, serial := range ds.macToSerial { + t.Logf(" Mapping: '%s' -> '%s'", mac, serial) + } + ds.idMutex.RUnlock() + }) + } +} + +// TestNormalizationSuggestion tests if we should implement MAC address normalization +func TestNormalizationSuggestion(t *testing.T) { + // This test demonstrates how MAC address normalization could solve the issue + + normalizeMAC := func(mac string) string { + // Remove common separators and convert to uppercase + mac = strings.ReplaceAll(mac, ":", "") + mac = strings.ReplaceAll(mac, "-", "") + mac = strings.ToUpper(mac) + return mac + } + + testCases := []struct { + original string + normalized string + }{ + {"A81B6A536A98", "A81B6A536A98"}, + {"a81b6a536a98", "A81B6A536A98"}, + {"A8:1B:6A:53:6A:98", "A81B6A536A98"}, + {"a8:1b:6a:53:6a:98", "A81B6A536A98"}, + {"A8-1B-6A-53-6A-98", "A81B6A536A98"}, + {"a8-1b-6a-53-6a-98", "A81B6A536A98"}, + {"a81B6a536A98", "A81B6A536A98"}, + } + + t.Log("MAC Address Normalization Test:") + t.Log("This shows how normalization could solve case/format sensitivity issues") + t.Log("") + + allNormalizedSame := true + expectedNormalized := "A81B6A536A98" + + for _, tc := range testCases { + normalized := normalizeMAC(tc.original) + matches := normalized == expectedNormalized + + if !matches { + allNormalizedSame = false + } + + t.Logf("'%s' -> '%s' (matches expected: %v)", tc.original, normalized, matches) + } + + if allNormalizedSame { + t.Log("") + t.Log("✓ All MAC address formats normalize to the same value") + t.Log("✓ Implementing normalization would solve case/format sensitivity issues") + } else { + t.Error("✗ Normalization failed to produce consistent results") + } +} diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index d7af7e0..8880d11 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -9,6 +9,7 @@ import ( "path/filepath" "sort" "strconv" + "strings" "sync" "time" @@ -30,6 +31,21 @@ type DataStore struct { macToSerial map[string]string } +// normalizeMAC normalizes a MAC address to a consistent format +func normalizeMAC(mac string) string { + if mac == "" { + return "" + } + // Remove spaces and common separators, then convert to uppercase + mac = strings.TrimSpace(mac) + mac = strings.ReplaceAll(mac, " ", "") + mac = strings.ReplaceAll(mac, ":", "") + mac = strings.ReplaceAll(mac, "-", "") + mac = strings.ToUpper(mac) + + return mac +} + // NewDataStore creates a new DataStore. // NewDataStore creates a new DataStore instance with the specified data directory. func NewDataStore(dataDir string) *DataStore { @@ -57,7 +73,14 @@ func (ds *DataStore) AccountDevicesDir(account string) string { // AccountDeviceDir returns the directory path for a specific device within an account. func (ds *DataStore) AccountDeviceDir(account, device string) string { ds.idMutex.RLock() + serial, ok := ds.macToSerial[device] + if !ok { + // Try with normalized MAC address + normalizedDevice := normalizeMAC(device) + serial, ok = ds.macToSerial[normalizedDevice] + } + ds.idMutex.RUnlock() if ok { @@ -661,7 +684,13 @@ func (ds *DataStore) UpdateMapping(mac, serial string) { ds.idMutex.Lock() defer ds.idMutex.Unlock() + // Store both the original MAC and the normalized version ds.macToSerial[mac] = serial + + normalizedMAC := normalizeMAC(mac) + if normalizedMAC != mac { + ds.macToSerial[normalizedMAC] = serial + } } // Initialize creates the necessary directory structure for the datastore and populates ID mappings. diff --git a/pkg/service/datastore/mac_mapping_diagnostic_test.go b/pkg/service/datastore/mac_mapping_diagnostic_test.go new file mode 100644 index 0000000..9cbe877 --- /dev/null +++ b/pkg/service/datastore/mac_mapping_diagnostic_test.go @@ -0,0 +1,259 @@ +package datastore + +import ( + "os" + "path/filepath" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/service/constants" +) + +func TestMacMappingDiagnostic(t *testing.T) { + // Test the exact scenario described in the issue + tmpDir, err := os.MkdirTemp("", "mac-mapping-diagnostic") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + accountID := "3230304" + serialNumber := "I6332527703739342000020" + macAddress := "A81B6A536A98" + + // Create the directory structure as it exists in production + deviceDir := filepath.Join(tmpDir, "accounts", accountID, "devices", serialNumber) + if err := os.MkdirAll(deviceDir, 0755); err != nil { + t.Fatalf("failed to create device dir: %v", err) + } + + // Create DeviceInfo.xml with the MAC address + deviceInfoXML := ` + + SoundTouch Device + + + SCM + 4.8.1 + ` + serialNumber + ` + + + + ` + macAddress + ` + 192.168.1.100 + +` + if err := os.WriteFile(filepath.Join(deviceDir, constants.DeviceInfoFile), []byte(deviceInfoXML), 0644); err != nil { + t.Fatalf("failed to write DeviceInfo.xml: %v", err) + } + + // Create Presets.xml to simulate the file that should be found + presetsXML := ` + + + + My Preset + + +` + if err := os.WriteFile(filepath.Join(deviceDir, constants.PresetsFile), []byte(presetsXML), 0644); err != nil { + t.Fatalf("failed to write Presets.xml: %v", err) + } + + // Initialize the datastore + ds := NewDataStore(tmpDir) + if err := ds.Initialize(); err != nil { + t.Fatalf("failed to initialize datastore: %v", err) + } + + // Test 1: Check if the mapping was populated + t.Run("CheckMappingPopulation", func(t *testing.T) { + ds.idMutex.RLock() + serial, ok := ds.macToSerial[macAddress] + ds.idMutex.RUnlock() + + if !ok { + t.Errorf("MAC address %s not found in mapping", macAddress) + } else if serial != serialNumber { + t.Errorf("MAC address %s mapped to %s, expected %s", macAddress, serial, serialNumber) + } else { + t.Logf("✓ MAC address %s correctly mapped to %s", macAddress, serial) + } + }) + + // Test 2: Check AccountDeviceDir resolution + t.Run("CheckAccountDeviceDir", func(t *testing.T) { + // Test with MAC address (should resolve to serial) + resolvedDir := ds.AccountDeviceDir(accountID, macAddress) + expectedDir := filepath.Join(tmpDir, "accounts", accountID, "devices", serialNumber) + + if resolvedDir != expectedDir { + t.Errorf("AccountDeviceDir with MAC %s resolved to %s, expected %s", macAddress, resolvedDir, expectedDir) + } else { + t.Logf("✓ AccountDeviceDir correctly resolved MAC %s to path %s", macAddress, resolvedDir) + } + + // Test with serial number (should work as-is) + resolvedDirSerial := ds.AccountDeviceDir(accountID, serialNumber) + if resolvedDirSerial != expectedDir { + t.Errorf("AccountDeviceDir with serial %s resolved to %s, expected %s", serialNumber, resolvedDirSerial, expectedDir) + } else { + t.Logf("✓ AccountDeviceDir works correctly with serial number %s", serialNumber) + } + }) + + // Test 3: Check GetPresets functionality with MAC address + t.Run("CheckGetPresetsWithMAC", func(t *testing.T) { + presets, err := ds.GetPresets(accountID, macAddress) + if err != nil { + t.Errorf("GetPresets failed with MAC address %s: %v", macAddress, err) + } else if len(presets) == 0 { + t.Error("GetPresets returned no presets") + } else { + t.Logf("✓ GetPresets successfully returned %d presets using MAC address %s", len(presets), macAddress) + } + }) + + // Test 4: Check GetPresets functionality with serial number + t.Run("CheckGetPresetsWithSerial", func(t *testing.T) { + presets, err := ds.GetPresets(accountID, serialNumber) + if err != nil { + t.Errorf("GetPresets failed with serial number %s: %v", serialNumber, err) + } else if len(presets) == 0 { + t.Error("GetPresets returned no presets") + } else { + t.Logf("✓ GetPresets successfully returned %d presets using serial number %s", len(presets), serialNumber) + } + }) + + // Test 5: Check case sensitivity + t.Run("CheckCaseSensitivity", func(t *testing.T) { + lowercaseMAC := "a81b6a536a98" + uppercaseMAC := "A81B6A536A98" + + ds.idMutex.RLock() + _, lowercaseOk := ds.macToSerial[lowercaseMAC] + _, uppercaseOk := ds.macToSerial[uppercaseMAC] + ds.idMutex.RUnlock() + + t.Logf("Lowercase MAC '%s' in mapping: %v", lowercaseMAC, lowercaseOk) + t.Logf("Uppercase MAC '%s' in mapping: %v", uppercaseMAC, uppercaseOk) + + // Test GetPresets with different cases + _, errLower := ds.GetPresets(accountID, lowercaseMAC) + _, errUpper := ds.GetPresets(accountID, uppercaseMAC) + + t.Logf("GetPresets with lowercase MAC error: %v", errLower) + t.Logf("GetPresets with uppercase MAC error: %v", errUpper) + }) + + // Test 6: Dump all mappings for debugging + t.Run("DumpMappings", func(t *testing.T) { + ds.idMutex.RLock() + defer ds.idMutex.RUnlock() + + t.Logf("Total mappings found: %d", len(ds.macToSerial)) + for mac, serial := range ds.macToSerial { + t.Logf(" MAC '%s' -> Serial '%s'", mac, serial) + } + }) + + // Test 7: Check actual file paths + t.Run("CheckFilePaths", func(t *testing.T) { + // Path that should work (with serial number) + correctPath := filepath.Join(tmpDir, "accounts", accountID, "devices", serialNumber, constants.PresetsFile) + if _, err := os.Stat(correctPath); err != nil { + t.Errorf("File not found at correct path %s: %v", correctPath, err) + } else { + t.Logf("✓ File found at correct path: %s", correctPath) + } + + // Path that would be wrong (with MAC address) + wrongPath := filepath.Join(tmpDir, "accounts", accountID, "devices", macAddress, constants.PresetsFile) + if _, err := os.Stat(wrongPath); err == nil { + t.Logf("⚠ïļ File also found at MAC path (unexpected): %s", wrongPath) + } else { + t.Logf("✓ File correctly not found at MAC path: %s", wrongPath) + } + }) +} + +// TestMacMappingWithDifferentFormats tests various MAC address formats +func TestMacMappingWithDifferentFormats(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "mac-format-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + testCases := []struct { + name string + macInXML string + macInRequest string + shouldWork bool + }{ + {"ExactMatch", "A81B6A536A98", "A81B6A536A98", true}, + {"LowerCase", "A81B6A536A98", "a81b6a536a98", true}, // Should work with normalization + {"UpperCase", "a81b6a536a98", "A81B6A536A98", true}, // Should work with normalization + {"WithColons", "A8:1B:6A:53:6A:98", "A81B6A536A98", true}, // Should work with normalization + {"WithDashes", "A8-1B-6A-53-6A-98", "A81B6A536A98", true}, // Should work with normalization + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Create separate directory for each test case + testDir := filepath.Join(tmpDir, tc.name) + accountID := "12345" + serialNumber := "TEST123456789" + + deviceDir := filepath.Join(testDir, "accounts", accountID, "devices", serialNumber) + if err := os.MkdirAll(deviceDir, 0755); err != nil { + t.Fatalf("failed to create device dir: %v", err) + } + + // Create DeviceInfo.xml with the specific MAC format + deviceInfoXML := ` + + Test Device + + + SCM + 4.8.1 + ` + serialNumber + ` + + + + ` + tc.macInXML + ` + 192.168.1.100 + +` + if err := os.WriteFile(filepath.Join(deviceDir, constants.DeviceInfoFile), []byte(deviceInfoXML), 0644); err != nil { + t.Fatalf("failed to write DeviceInfo.xml: %v", err) + } + + // Create Presets.xml + presetsXML := `test` + if err := os.WriteFile(filepath.Join(deviceDir, constants.PresetsFile), []byte(presetsXML), 0644); err != nil { + t.Fatalf("failed to write Presets.xml: %v", err) + } + + // Initialize datastore + ds := NewDataStore(testDir) + if err := ds.Initialize(); err != nil { + t.Fatalf("failed to initialize datastore: %v", err) + } + + // Try to get presets using the request MAC format + _, err := ds.GetPresets(accountID, tc.macInRequest) + + if tc.shouldWork && err != nil { + t.Errorf("Expected success but got error: %v", err) + } else if !tc.shouldWork && err == nil { + t.Errorf("Expected failure but got success") + } else if tc.shouldWork { + t.Logf("✓ Successfully resolved MAC '%s' to serial '%s' (normalization worked)", tc.macInRequest, serialNumber) + } else { + t.Logf("✓ Correctly failed to resolve MAC '%s' (XML had '%s')", tc.macInRequest, tc.macInXML) + } + }) + } +} diff --git a/pkg/service/datastore/upnp_integration_test.go b/pkg/service/datastore/upnp_integration_test.go new file mode 100644 index 0000000..f02089e --- /dev/null +++ b/pkg/service/datastore/upnp_integration_test.go @@ -0,0 +1,386 @@ +package datastore + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/discovery" + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/constants" +) + +func TestUPnPDiscoveryToDatastoreMapping_FullFlow(t *testing.T) { + // This test demonstrates the complete flow: + // 1. UPnP discovery finds device with MAC in serialNumber + // 2. Device is stored in datastore with serial number directory + // 3. MAC address mapping is established + // 4. HTTP requests using MAC address are resolved to correct directory + + tmpDir, err := os.MkdirTemp("", "upnp-datastore-integration") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Test data matching the user's scenario + accountID := "3230304" + deviceSerial := "I6332527703739342000020" + deviceMAC := "A81B6A536A98" + deviceName := "Sound Machinechen" + + t.Logf("Test scenario:") + t.Logf(" Account: %s", accountID) + t.Logf(" Device Serial: %s", deviceSerial) + t.Logf(" Device MAC: %s", deviceMAC) + t.Logf(" Expected directory: accounts/%s/devices/%s/", accountID, deviceSerial) + t.Logf(" Expected request: GET /streaming/account/%s/device/%s/presets", accountID, deviceMAC) + t.Logf("") + + // Step 1: Create the device directory structure using serial number + deviceDir := filepath.Join(tmpDir, "accounts", accountID, "devices", deviceSerial) + if err := os.MkdirAll(deviceDir, 0755); err != nil { + t.Fatalf("failed to create device dir: %v", err) + } + + // Create DeviceInfo.xml with MAC address + deviceInfoXML := ` + + ` + deviceName + ` + + + SCM + 4.8.1 + ` + deviceSerial + ` + + + + ` + deviceMAC + ` + 192.168.178.35 + +` + if err := os.WriteFile(filepath.Join(deviceDir, constants.DeviceInfoFile), []byte(deviceInfoXML), 0644); err != nil { + t.Fatalf("failed to write DeviceInfo.xml: %v", err) + } + + // Create Presets.xml (the target file we want to access) + presetsXML := ` + + + + My Spotify Station + + + + + NPR News + + +` + if err := os.WriteFile(filepath.Join(deviceDir, constants.PresetsFile), []byte(presetsXML), 0644); err != nil { + t.Fatalf("failed to write Presets.xml: %v", err) + } + + // Step 2: Simulate UPnP discovery with real device XML + t.Run("Step2_UPnPDiscovery", func(t *testing.T) { + // UPnP XML exactly as provided by the user + upnpXML := ` + + + 1 + 0 + + + urn:schemas-upnp-org:device:MediaRenderer:1 + ` + deviceName + ` + QPlay:2 + Bose Corporation + http://www.bose.com + SoundTouch 10 + + Bose SoundTouch Wireless Streaming Audio Device + http://www.bose.com + ` + deviceMAC + ` + uuid:BO5EBO5E-F00D-F00D-FEED-` + deviceMAC + ` + + + urn:schemas-upnp-org:service:AVTransport:1 + urn:upnp-org:serviceId:AVTransport + /Xml/AVTransport3.xml + /AVTransport/Control + /AVTransport/Event + + + +` + + // Create UPnP server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/xml; charset=utf-8") + fmt.Fprint(w, upnpXML) + })) + defer server.Close() + + // Simulate UPnP discovery + discoveryService := discovery.NewService(5 * time.Second) + device := &models.DiscoveredDevice{ + Host: "192.168.178.35", + Port: 8091, + Name: "Initial Name", + } + + err := discoveryService.EnrichDeviceInfo(device, server.URL+"/XD/BO5EBO5E-F00D-F00D-FEED-"+deviceMAC+".xml") + if err != nil { + t.Errorf("UPnP enrichment failed: %v", err) + } else { + t.Logf("✓ UPnP discovery extracted MAC: '%s' from serialNumber", device.UPnPSerial) + } + + // Verify UPnP extraction + if device.UPnPSerial != deviceMAC { + t.Errorf("Expected UPnPSerial '%s', got '%s'", deviceMAC, device.UPnPSerial) + } + }) + + // Step 3: Initialize datastore and verify mapping + t.Run("Step3_DatastoreMapping", func(t *testing.T) { + ds := NewDataStore(tmpDir) + err := ds.Initialize() + if err != nil { + t.Fatalf("failed to initialize datastore: %v", err) + } + + // Verify mapping was created during initialization + ds.idMutex.RLock() + mappedSerial, hasMappingExact := ds.macToSerial[deviceMAC] + normalizedMAC := normalizeMAC(deviceMAC) + mappedSerialNormalized, hasMappingNormalized := ds.macToSerial[normalizedMAC] + ds.idMutex.RUnlock() + + t.Logf("Mapping check:") + t.Logf(" Original MAC '%s' -> mapped: %v", deviceMAC, hasMappingExact) + if hasMappingExact { + t.Logf(" Original MAC maps to: '%s'", mappedSerial) + } + t.Logf(" Normalized MAC '%s' -> mapped: %v", normalizedMAC, hasMappingNormalized) + if hasMappingNormalized { + t.Logf(" Normalized MAC maps to: '%s'", mappedSerialNormalized) + } + + if !hasMappingExact && !hasMappingNormalized { + t.Error("No mapping found for MAC address") + } else { + t.Logf("✓ MAC address mapping established successfully") + } + }) + + // Step 4: Test HTTP request resolution + t.Run("Step4_HTTPRequestResolution", func(t *testing.T) { + ds := NewDataStore(tmpDir) + err := ds.Initialize() + if err != nil { + t.Fatalf("failed to initialize datastore: %v", err) + } + + // Test various MAC address formats in HTTP requests + testCases := []struct { + name string + requestMAC string + shouldWork bool + description string + }{ + { + name: "ExactMatch", + requestMAC: "A81B6A536A98", + shouldWork: true, + description: "Exact MAC match", + }, + { + name: "LowercaseMAC", + requestMAC: "a81b6a536a98", + shouldWork: true, + description: "Lowercase MAC (should work with normalization)", + }, + { + name: "MACWithColons", + requestMAC: "A8:1B:6A:53:6A:98", + shouldWork: true, + description: "MAC with colons (should work with normalization)", + }, + { + name: "MACWithDashes", + requestMAC: "A8-1B-6A-53-6A-98", + shouldWork: true, + description: "MAC with dashes (should work with normalization)", + }, + { + name: "InvalidMAC", + requestMAC: "INVALID123456", + shouldWork: false, + description: "Invalid MAC (should fail)", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Simulate HTTP request: GET /streaming/account/{account}/device/{device}/presets + presets, err := ds.GetPresets(accountID, tc.requestMAC) + + if tc.shouldWork { + if err != nil { + t.Errorf("%s failed: %v", tc.description, err) + } else if len(presets) == 0 { + t.Errorf("%s: no presets returned", tc.description) + } else { + t.Logf("✓ %s: Successfully retrieved %d presets", tc.description, len(presets)) + + // Verify preset content + if presets[0].ID == "1" && presets[1].ID == "2" { + t.Logf(" ✓ Preset content verified (IDs: %s, %s)", presets[0].ID, presets[1].ID) + } + } + } else { + if err == nil { + t.Errorf("%s: expected failure but got success", tc.description) + } else { + t.Logf("✓ %s: Correctly failed with error: %v", tc.description, err) + } + } + }) + } + }) + + // Step 5: Test directory resolution + t.Run("Step5_DirectoryResolution", func(t *testing.T) { + ds := NewDataStore(tmpDir) + err := ds.Initialize() + if err != nil { + t.Fatalf("failed to initialize datastore: %v", err) + } + + // Test AccountDeviceDir resolution + resolvedDirMAC := ds.AccountDeviceDir(accountID, deviceMAC) + resolvedDirSerial := ds.AccountDeviceDir(accountID, deviceSerial) + expectedDir := filepath.Join(tmpDir, "accounts", accountID, "devices", deviceSerial) + + t.Logf("Directory resolution:") + t.Logf(" Request with MAC '%s' -> '%s'", deviceMAC, resolvedDirMAC) + t.Logf(" Request with serial '%s' -> '%s'", deviceSerial, resolvedDirSerial) + t.Logf(" Expected directory: '%s'", expectedDir) + + if resolvedDirMAC != expectedDir { + t.Errorf("MAC resolution failed: expected '%s', got '%s'", expectedDir, resolvedDirMAC) + } else { + t.Logf("✓ MAC address correctly resolved to serial number directory") + } + + if resolvedDirSerial != expectedDir { + t.Errorf("Serial resolution failed: expected '%s', got '%s'", expectedDir, resolvedDirSerial) + } else { + t.Logf("✓ Serial number resolution works correctly") + } + }) + + // Step 6: Integration summary + t.Run("Step6_IntegrationSummary", func(t *testing.T) { + t.Log("") + t.Log("=== INTEGRATION SUMMARY ===") + t.Log("✅ UPnP Discovery: MAC address extracted from serialNumber field") + t.Log("✅ Datastore Initialization: MAC-to-serial mapping created from DeviceInfo.xml") + t.Log("✅ MAC Normalization: Case and format variations handled correctly") + t.Log("✅ HTTP Request Resolution: MAC addresses resolve to correct device directories") + t.Log("✅ File Access: Presets.xml found using MAC address in request URL") + t.Log("") + t.Log("The original issue has been resolved:") + t.Logf(" Request: GET /streaming/account/%s/device/%s/presets", accountID, deviceMAC) + t.Logf(" Resolves to: %s/accounts/%s/devices/%s/Presets.xml", tmpDir, accountID, deviceSerial) + t.Log("") + }) +} + +func TestNormalizationEdgeCases(t *testing.T) { + testCases := []struct { + input string + expected string + desc string + }{ + {"", "", "empty string"}, + {"a", "A", "single character"}, + {"ab", "AB", "two characters"}, + {"A81B6A536A98", "A81B6A536A98", "standard MAC"}, + {"a81b6a536a98", "A81B6A536A98", "lowercase MAC"}, + {"A8:1B:6A:53:6A:98", "A81B6A536A98", "MAC with colons"}, + {"A8-1B-6A-53-6A-98", "A81B6A536A98", "MAC with dashes"}, + {"a8:1b:6a:53:6a:98", "A81B6A536A98", "lowercase MAC with colons"}, + {"a8-1b-6a-53-6a-98", "A81B6A536A98", "lowercase MAC with dashes"}, + {"A8::1B::6A", "A81B6A", "multiple consecutive colons"}, + {"A8--1B--6A", "A81B6A", "multiple consecutive dashes"}, + {"A8:-1B-:6A", "A81B6A", "mixed separators"}, + {" A81B6A536A98 ", "A81B6A536A98", "MAC with spaces (handled by normalization)"}, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + result := normalizeMAC(tc.input) + if result != tc.expected { + t.Errorf("normalizeMAC(%q) = %q, expected %q", tc.input, result, tc.expected) + } else { + t.Logf("✓ %s: %q -> %q", tc.desc, tc.input, result) + } + }) + } +} + +func TestMACMappingPerformance(t *testing.T) { + // Test performance with many mappings + tmpDir, err := os.MkdirTemp("", "mac-performance-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + ds := NewDataStore(tmpDir) + + // Add many mappings + numMappings := 1000 + t.Logf("Testing performance with %d MAC mappings...", numMappings) + + start := time.Now() + for i := 0; i < numMappings; i++ { + mac := fmt.Sprintf("AA:BB:CC:DD:EE:%02X", i%256) + serial := fmt.Sprintf("SERIAL%06d", i) + ds.UpdateMapping(mac, serial) + } + updateDuration := time.Since(start) + + // Test lookup performance + start = time.Now() + for i := 0; i < numMappings; i++ { + mac := fmt.Sprintf("AA:BB:CC:DD:EE:%02X", i%256) + accountID := "test" + _ = ds.AccountDeviceDir(accountID, mac) + } + lookupDuration := time.Since(start) + + t.Logf("✓ Performance test completed:") + t.Logf(" Update %d mappings: %v (%.2f Ξs per mapping)", numMappings, updateDuration, float64(updateDuration.Nanoseconds())/float64(numMappings)/1000.0) + t.Logf(" Lookup %d mappings: %v (%.2f Ξs per lookup)", numMappings, lookupDuration, float64(lookupDuration.Nanoseconds())/float64(numMappings)/1000.0) + + // Verify total mappings (should be more than numMappings due to normalization) + ds.idMutex.RLock() + totalMappings := len(ds.macToSerial) + ds.idMutex.RUnlock() + + t.Logf(" Total mappings stored: %d (includes normalized versions)", totalMappings) + + if updateDuration > time.Millisecond*100 { + t.Errorf("Update performance too slow: %v", updateDuration) + } + if lookupDuration > time.Millisecond*50 { + t.Errorf("Lookup performance too slow: %v", lookupDuration) + } +} diff --git a/pkg/service/handlers/mac_mapping_integration_test.go b/pkg/service/handlers/mac_mapping_integration_test.go new file mode 100644 index 0000000..0a02b0f --- /dev/null +++ b/pkg/service/handlers/mac_mapping_integration_test.go @@ -0,0 +1,336 @@ +package handlers + +import ( + "encoding/xml" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/service/constants" + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" + "github.com/go-chi/chi/v5" +) + +func TestMacMappingIntegration_HTTPHandler(t *testing.T) { + // Create temporary directory + tmpDir, err := os.MkdirTemp("", "mac-integration-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Setup test data (same as the issue description) + accountID := "3230304" + serialNumber := "I6332527703739342000020" + macAddress := "A81B6A536A98" + + // Create directory structure using serial number + deviceDir := filepath.Join(tmpDir, "accounts", accountID, "devices", serialNumber) + if err := os.MkdirAll(deviceDir, 0755); err != nil { + t.Fatalf("failed to create device dir: %v", err) + } + + // Create DeviceInfo.xml with MAC address + deviceInfoXML := ` + + SoundTouch Device + + + SCM + 4.8.1 + ` + serialNumber + ` + + + + ` + macAddress + ` + 192.168.1.100 + +` + if err := os.WriteFile(filepath.Join(deviceDir, constants.DeviceInfoFile), []byte(deviceInfoXML), 0644); err != nil { + t.Fatalf("failed to write DeviceInfo.xml: %v", err) + } + + // Create Presets.xml + presetsXML := ` + + + + Test Preset + + + + + Radio Station + + +` + if err := os.WriteFile(filepath.Join(deviceDir, constants.PresetsFile), []byte(presetsXML), 0644); err != nil { + t.Fatalf("failed to write Presets.xml: %v", err) + } + + // Set a specific modification time for ETag testing + pastTime := time.Now().Add(-1 * time.Hour) + if err := os.Chtimes(filepath.Join(deviceDir, constants.PresetsFile), pastTime, pastTime); err != nil { + t.Fatalf("failed to set file times: %v", err) + } + + // Create Sources.xml (required by marge.PresetsToXML) + sourcesXML := ` + + + Spotify + + + TuneIn + +` + if err := os.WriteFile(filepath.Join(deviceDir, constants.SourcesFile), []byte(sourcesXML), 0644); err != nil { + t.Fatalf("failed to write Sources.xml: %v", err) + } + + // Initialize datastore and server + ds := datastore.NewDataStore(tmpDir) + if err := ds.Initialize(); err != nil { + t.Fatalf("failed to initialize datastore: %v", err) + } + + server := &Server{ + ds: ds, + } + + // Setup router with the exact same route as in production + router := chi.NewRouter() + router.Route("/streaming", func(r chi.Router) { + r.Get("/account/{account}/device/{device}/presets", server.HandleMargePresets) + }) + + // Test 1: Request with MAC address (should work due to mapping) + t.Run("RequestWithMACAddress", func(t *testing.T) { + requestURL := "/streaming/account/" + accountID + "/device/" + macAddress + "/presets" + req, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d. Response body: %s", rr.Code, rr.Body.String()) + t.Logf("Request URL: %s", requestURL) + t.Logf("MAC address: %s", macAddress) + t.Logf("Serial number: %s", serialNumber) + return + } + + // Verify response contains the expected presets + var presetsResponse struct { + Presets []struct { + ID string `xml:"id,attr"` + Name string `xml:"ContentItem>itemName"` + } `xml:"preset"` + } + + if err := xml.Unmarshal(rr.Body.Bytes(), &presetsResponse); err != nil { + t.Errorf("Failed to parse XML response: %v", err) + t.Logf("Response body: %s", rr.Body.String()) + return + } + + if len(presetsResponse.Presets) != 2 { + t.Errorf("Expected 2 presets, got %d", len(presetsResponse.Presets)) + } + + t.Logf("✓ Successfully retrieved %d presets using MAC address %s", len(presetsResponse.Presets), macAddress) + }) + + // Test 2: Request with serial number (should also work) + t.Run("RequestWithSerialNumber", func(t *testing.T) { + requestURL := "/streaming/account/" + accountID + "/device/" + serialNumber + "/presets" + req, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d. Response body: %s", rr.Code, rr.Body.String()) + return + } + + t.Logf("✓ Successfully retrieved presets using serial number %s", serialNumber) + }) + + // Test 3: Request with non-existent device ID + t.Run("RequestWithNonExistentDevice", func(t *testing.T) { + requestURL := "/streaming/account/" + accountID + "/device/NONEXISTENT/presets" + req, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusInternalServerError { + t.Errorf("Expected status 500 for non-existent device, got %d", rr.Code) + } + + t.Logf("✓ Correctly returned error for non-existent device") + }) + + // Test 4: Case sensitivity test + t.Run("RequestWithLowercaseMAC", func(t *testing.T) { + lowercaseMAC := "a81b6a536a98" + requestURL := "/streaming/account/" + accountID + "/device/" + lowercaseMAC + "/presets" + req, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + // This should fail because MAC addresses are case-sensitive + if rr.Code == http.StatusOK { + t.Logf("⚠ïļ Lowercase MAC address worked (might be unexpected): %s", lowercaseMAC) + } else { + t.Logf("✓ Lowercase MAC address correctly failed: %s (status: %d)", lowercaseMAC, rr.Code) + } + }) + + // Test 5: Verify ETag functionality + t.Run("RequestWithETag", func(t *testing.T) { + requestURL := "/streaming/account/" + accountID + "/device/" + macAddress + "/presets" + + // First request to get ETag + req1, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + rr1 := httptest.NewRecorder() + router.ServeHTTP(rr1, req1) + + if rr1.Code != http.StatusOK { + t.Errorf("First request failed with status %d", rr1.Code) + return + } + + // Extract ETag from response headers (direct access needed for httptest.ResponseRecorder) + etag := "" + //nolint:staticcheck // SA1008: ETag header name must be case-sensitive for test + if vals, ok := rr1.Header()["ETag"]; ok && len(vals) > 0 { + etag = vals[0] + } + + if etag == "" { + t.Errorf("No ETag header in response. Available headers: %v", rr1.Header()) + return + } + + // Second request with ETag + req2, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + t.Fatalf("failed to create second request: %v", err) + } + req2.Header.Set("If-None-Match", etag) + + rr2 := httptest.NewRecorder() + router.ServeHTTP(rr2, req2) + + if rr2.Code != http.StatusNotModified { + t.Errorf("Expected 304 Not Modified, got %d", rr2.Code) + return + } + + t.Logf("✓ ETag functionality works correctly with MAC address resolution") + }) +} + +// TestMacMappingDebug provides debugging information about the mapping state +func TestMacMappingDebug(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "mac-debug-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create multiple devices to test mapping + devices := []struct { + account string + serial string + mac string + }{ + {"3230304", "I6332527703739342000020", "A81B6A536A98"}, + {"3230304", "J1234567890123456789012", "B92C7B647BA9"}, + {"5678901", "K9876543210987654321098", "C03D8C758CAA"}, + } + + for _, device := range devices { + deviceDir := filepath.Join(tmpDir, "accounts", device.account, "devices", device.serial) + if err := os.MkdirAll(deviceDir, 0755); err != nil { + t.Fatalf("failed to create device dir: %v", err) + } + + deviceInfoXML := ` + + Device ` + device.serial[0:8] + ` + + + SCM + ` + device.serial + ` + + + + ` + device.mac + ` + 192.168.1.100 + +` + if err := os.WriteFile(filepath.Join(deviceDir, constants.DeviceInfoFile), []byte(deviceInfoXML), 0644); err != nil { + t.Fatalf("failed to write DeviceInfo.xml: %v", err) + } + + // Create minimal Sources.xml for each device + sourcesXML := `` + if err := os.WriteFile(filepath.Join(deviceDir, constants.SourcesFile), []byte(sourcesXML), 0644); err != nil { + t.Fatalf("failed to write Sources.xml: %v", err) + } + } + + // Initialize datastore + ds := datastore.NewDataStore(tmpDir) + if err := ds.Initialize(); err != nil { + t.Fatalf("failed to initialize datastore: %v", err) + } + + // Debug output + allDevices, err := ds.ListAllDevices() + if err != nil { + t.Fatalf("failed to list devices: %v", err) + } + + t.Logf("Found %d devices total", len(allDevices)) + for _, dev := range allDevices { + t.Logf("Device: Account=%s, Serial=%s, MAC=%s", + dev.AccountID, dev.DeviceSerialNumber, dev.MacAddress) + } + + // Test each mapping + for _, device := range devices { + resolvedDir := ds.AccountDeviceDir(device.account, device.mac) + expectedDir := filepath.Join(tmpDir, "accounts", device.account, "devices", device.serial) + + if resolvedDir == expectedDir { + t.Logf("✓ MAC %s correctly resolves to serial %s", device.mac, device.serial) + } else { + t.Errorf("✗ MAC %s resolution failed: got %s, expected %s", + device.mac, resolvedDir, expectedDir) + } + } +}