mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
feat: implement robust MAC address to serial number mapping
Enhances device identification by adding MAC address normalization and comprehensive documentation. - Add `MAC-ADDRESS-MAPPING.md` guide explaining device identification and troubleshooting. - Implement `normalizeMAC` in `DataStore` to handle various MAC formats (case-insensitive, with/without separators). - Export `EnrichDeviceInfo` in UPnP discovery to allow better integration and testing. - Update `TROUBLESHOOTING.md` with a new section on device identification issues. - Add comprehensive integration and diagnostic tests for MAC mapping, case sensitivity, and UPnP discovery. - Update documentation structure (`README.md`, `SUMMARY.md`) to include the new mapping guide.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
<!-- From http://192.168.1.100:8091/XD/BO5EBO5E-F00D-F00D-FEED-A81B6A536A98.xml -->
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<device>
|
||||
<friendlyName>Sound Machinery</friendlyName>
|
||||
<modelName>SoundTouch 10</modelName>
|
||||
<serialNumber>A81B6A536A98</serialNumber> <!-- MAC address here -->
|
||||
</device>
|
||||
</root>
|
||||
```
|
||||
|
||||
## ⚙️ **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 <macAddress> 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
|
||||
<info deviceID="I6332527703739342000020">
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>A81B6A536A98</macAddress> <!-- Must be present -->
|
||||
<ipAddress>192.168.178.35</ipAddress>
|
||||
</networkInfo>
|
||||
</info>
|
||||
```
|
||||
|
||||
#### 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
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="I6332527703739342000020">
|
||||
<name>My SoundTouch Device</name>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>A81B6A536A98</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
</info>
|
||||
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 <serialNumber> 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<specVersion>
|
||||
<major>1</major>
|
||||
<minor>0</minor>
|
||||
</specVersion>
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
|
||||
<friendlyName>Sound Machinechen</friendlyName>
|
||||
<qq:X_QPlay_SoftwareCapability xmlns:qq="http://www.tencent.com">QPlay:2</qq:X_QPlay_SoftwareCapability>
|
||||
<manufacturer>Bose Corporation</manufacturer>
|
||||
<manufacturerURL>http://www.bose.com</manufacturerURL>
|
||||
<modelName>SoundTouch 10</modelName>
|
||||
<modelNumber></modelNumber>
|
||||
<modelDescription>Bose SoundTouch Wireless Streaming Audio Device</modelDescription>
|
||||
<modelURL>http://www.bose.com</modelURL>
|
||||
<serialNumber>A81B6A536A98</serialNumber>
|
||||
<UDN>uuid:BO5EBO5E-F00D-F00D-FEED-A81B6A536A98</UDN>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
|
||||
<serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
|
||||
<SCPDURL>/Xml/AVTransport3.xml</SCPDURL>
|
||||
<controlURL>/AVTransport/Control</controlURL>
|
||||
<eventSubURL>/AVTransport/Event</eventSubURL>
|
||||
</service>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
|
||||
<serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
|
||||
<SCPDURL>/Xml/ConnectionManager3.xml</SCPDURL>
|
||||
<controlURL>/ConnectionManager/Control</controlURL>
|
||||
<eventSubURL>/ConnectionManager/Event</eventSubURL>
|
||||
</service>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:RenderingControl:1</serviceType>
|
||||
<serviceId>urn:upnp-org:serviceId:RenderingControl</serviceId>
|
||||
<SCPDURL>/Xml/RenderingControl3.xml</SCPDURL>
|
||||
<controlURL>/RenderingControl/Control</controlURL>
|
||||
<eventSubURL>/RenderingControl/Event</eventSubURL>
|
||||
</service>
|
||||
<service>
|
||||
<serviceType>urn:schemas-tencent-com:service:QPlay:2</serviceType>
|
||||
<serviceId>urn:tencent-com:serviceId:QPlay</serviceId>
|
||||
<controlURL>/QPlay/Control</controlURL>
|
||||
<eventSubURL>/QPlay/Event</eventSubURL>
|
||||
<SCPDURL>/Xml/QPlay.xml</SCPDURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</root>`
|
||||
|
||||
// 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 := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
|
||||
<friendlyName>Test Device</friendlyName>
|
||||
<manufacturer>Bose Corporation</manufacturer>
|
||||
<modelName>SoundTouch 10</modelName>
|
||||
<serialNumber>%s</serialNumber>
|
||||
<UDN>uuid:BO5EBO5E-F00D-F00D-FEED-TEST</UDN>
|
||||
</device>
|
||||
</root>`
|
||||
|
||||
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 := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
|
||||
<friendlyName>Sound Machinechen</friendlyName>
|
||||
<manufacturer>Bose Corporation</manufacturer>
|
||||
<modelName>SoundTouch 10</modelName>
|
||||
<serialNumber>A81B6A536A98</serialNumber>
|
||||
<UDN>uuid:BO5EBO5E-F00D-F00D-FEED-A81B6A536A98</UDN>
|
||||
</device>
|
||||
</root>`
|
||||
|
||||
// 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 := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<device>
|
||||
<friendlyName>Test Device</friendlyName>
|
||||
<modelName>Test Model</modelName>
|
||||
</device>
|
||||
</root>`
|
||||
|
||||
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)")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="` + serialNumber + `">
|
||||
<name>Test Device</name>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>4.8.1</softwareVersion>
|
||||
<serialNumber>` + serialNumber + `</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>` + tc.macInDeviceInfo + `</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
</info>`
|
||||
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 := `<presets><preset id="1">test</preset></presets>`
|
||||
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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="` + serialNumber + `">
|
||||
<name>SoundTouch Device</name>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>4.8.1</softwareVersion>
|
||||
<serialNumber>` + serialNumber + `</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>` + macFormat + `</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
</info>`
|
||||
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 := `<presets><preset id="1">test</preset></presets>`
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="` + serialNumber + `">
|
||||
<name>SoundTouch Device</name>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>4.8.1</softwareVersion>
|
||||
<serialNumber>` + serialNumber + `</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>` + macAddress + `</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
</info>`
|
||||
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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets>
|
||||
<preset id="1">
|
||||
<ContentItem source="SPOTIFY" type="station" location="/station/abc123" sourceAccount="spotify_user">
|
||||
<itemName>My Preset</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="` + serialNumber + `">
|
||||
<name>Test Device</name>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>4.8.1</softwareVersion>
|
||||
<serialNumber>` + serialNumber + `</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>` + tc.macInXML + `</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
</info>`
|
||||
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 := `<presets><preset id="1">test</preset></presets>`
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="` + deviceSerial + `">
|
||||
<name>` + deviceName + `</name>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>4.8.1</softwareVersion>
|
||||
<serialNumber>` + deviceSerial + `</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>` + deviceMAC + `</macAddress>
|
||||
<ipAddress>192.168.178.35</ipAddress>
|
||||
</networkInfo>
|
||||
</info>`
|
||||
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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets>
|
||||
<preset id="1">
|
||||
<ContentItem source="SPOTIFY" type="station" location="/station/test123" sourceAccount="spotify">
|
||||
<itemName>My Spotify Station</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<preset id="2">
|
||||
<ContentItem source="TUNEIN" type="station" location="/station/s12345" sourceAccount="">
|
||||
<itemName>NPR News</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
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 := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<specVersion>
|
||||
<major>1</major>
|
||||
<minor>0</minor>
|
||||
</specVersion>
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
|
||||
<friendlyName>` + deviceName + `</friendlyName>
|
||||
<qq:X_QPlay_SoftwareCapability xmlns:qq="http://www.tencent.com">QPlay:2</qq:X_QPlay_SoftwareCapability>
|
||||
<manufacturer>Bose Corporation</manufacturer>
|
||||
<manufacturerURL>http://www.bose.com</manufacturerURL>
|
||||
<modelName>SoundTouch 10</modelName>
|
||||
<modelNumber></modelNumber>
|
||||
<modelDescription>Bose SoundTouch Wireless Streaming Audio Device</modelDescription>
|
||||
<modelURL>http://www.bose.com</modelURL>
|
||||
<serialNumber>` + deviceMAC + `</serialNumber>
|
||||
<UDN>uuid:BO5EBO5E-F00D-F00D-FEED-` + deviceMAC + `</UDN>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
|
||||
<serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
|
||||
<SCPDURL>/Xml/AVTransport3.xml</SCPDURL>
|
||||
<controlURL>/AVTransport/Control</controlURL>
|
||||
<eventSubURL>/AVTransport/Event</eventSubURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</root>`
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="` + serialNumber + `">
|
||||
<name>SoundTouch Device</name>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>4.8.1</softwareVersion>
|
||||
<serialNumber>` + serialNumber + `</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>` + macAddress + `</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
</info>`
|
||||
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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets>
|
||||
<preset id="1">
|
||||
<ContentItem source="SPOTIFY" type="station" location="/station/test123" sourceAccount="spotify_user">
|
||||
<itemName>Test Preset</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<preset id="2">
|
||||
<ContentItem source="TUNEIN" type="station" location="/station/s12345" sourceAccount="">
|
||||
<itemName>Radio Station</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sources>
|
||||
<source source="SPOTIFY" sourceAccount="spotify_user" status="READY" multiroomallowed="true">
|
||||
<sourceName>Spotify</sourceName>
|
||||
</source>
|
||||
<source source="TUNEIN" sourceAccount="" status="READY" multiroomallowed="true">
|
||||
<sourceName>TuneIn</sourceName>
|
||||
</source>
|
||||
</sources>`
|
||||
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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="` + device.serial + `">
|
||||
<name>Device ` + device.serial[0:8] + `</name>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<serialNumber>` + device.serial + `</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>` + device.mac + `</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
</info>`
|
||||
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 := `<sources></sources>`
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user