diff --git a/pkg/discovery/upnp.go b/pkg/discovery/upnp.go
index e642f25..835d717 100644
--- a/pkg/discovery/upnp.go
+++ b/pkg/discovery/upnp.go
@@ -2,8 +2,10 @@ package discovery
import (
"context"
+ "encoding/xml"
"errors"
"fmt"
+ "io"
"log"
"net"
"net/http"
@@ -418,7 +420,7 @@ func (d *Service) parseLocationURL(location, usn string) (*models.DiscoveredDevi
}
// enrichDeviceInfo tries to get additional device information from the device description
-func (d *Service) enrichDeviceInfo(_ *models.DiscoveredDevice, location string) error {
+func (d *Service) enrichDeviceInfo(device *models.DiscoveredDevice, location string) error {
log.Printf("UPnP: Attempting to enrich device info by fetching %s", location)
client := &http.Client{
@@ -437,8 +439,44 @@ func (d *Service) enrichDeviceInfo(_ *models.DiscoveredDevice, location string)
log.Printf("UPnP: Successfully fetched device description from %s (Status: %s)", location, resp.Status)
- // For now, we'll keep it simple and not parse the full UPnP device description
- // This can be enhanced later to extract more detailed device information
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+ }
+
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("failed to read body: %w", err)
+ }
+
+ var upnpRoot struct {
+ XMLName xml.Name `xml:"root"`
+ Device struct {
+ FriendlyName string `xml:"friendlyName"`
+ ModelName string `xml:"modelName"`
+ SerialNumber string `xml:"serialNumber"`
+ } `xml:"device"`
+ }
+
+ if err := xml.Unmarshal(data, &upnpRoot); err != nil {
+ log.Printf("UPnP: Failed to parse device description from %s: %v", location, err)
+ return err
+ }
+
+ if upnpRoot.Device.FriendlyName != "" {
+ device.Name = upnpRoot.Device.FriendlyName
+ }
+
+ if upnpRoot.Device.ModelName != "" {
+ device.ModelID = upnpRoot.Device.ModelName
+ }
+
+ if upnpRoot.Device.SerialNumber != "" {
+ device.UPnPSerial = upnpRoot.Device.SerialNumber
+ }
+
+ log.Printf("UPnP: Enriched device info: Name='%s', Model='%s', UPnPSerial='%s'",
+ device.Name, device.ModelID, device.UPnPSerial)
+
return nil
}
diff --git a/pkg/discovery/upnp_enrich_test.go b/pkg/discovery/upnp_enrich_test.go
new file mode 100644
index 0000000..c8b52d8
--- /dev/null
+++ b/pkg/discovery/upnp_enrich_test.go
@@ -0,0 +1,88 @@
+package discovery
+
+import (
+ "encoding/xml"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gesellix/bose-soundtouch/pkg/models"
+)
+
+func TestEnrichDeviceInfo(t *testing.T) {
+ // Mock UPnP device description XML
+ xmlData := `
+
+
+ Sound Machinery
+ SoundTouch 10
+ A81B6A536A09
+
+`
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/xml")
+ fmt.Fprint(w, xmlData)
+ }))
+ defer server.Close()
+
+ device := &models.DiscoveredDevice{
+ Host: "127.0.0.1",
+ Name: "Initial Name",
+ }
+
+ service := &Service{}
+ err := service.enrichDeviceInfo(device, server.URL)
+
+ if err != nil {
+ t.Fatalf("enrichDeviceInfo failed: %v", err)
+ }
+
+ if device.Name != "Sound Machinery" {
+ t.Errorf("expected Name 'Sound Machinery', got '%s'", device.Name)
+ }
+
+ if device.ModelID != "SoundTouch 10" {
+ t.Errorf("expected ModelID 'SoundTouch 10', got '%s'", device.ModelID)
+ }
+
+ if device.UPnPSerial != "A81B6A536A09" {
+ t.Errorf("expected UPnPSerial 'A81B6A536A09', got '%s'", device.UPnPSerial)
+ }
+}
+
+func TestUPnP_Unmarshal(t *testing.T) {
+ data := `
+
+
+ Sound Machinery
+ SoundTouch 10
+ A81B6A536A09
+
+`
+
+ var upnpRoot struct {
+ XMLName xml.Name `xml:"root"`
+ Device struct {
+ FriendlyName string `xml:"friendlyName"`
+ ModelName string `xml:"modelName"`
+ SerialNumber string `xml:"serialNumber"`
+ } `xml:"device"`
+ }
+
+ err := xml.Unmarshal([]byte(data), &upnpRoot)
+ if err != nil {
+ t.Fatalf("Unmarshal failed: %v", err)
+ }
+
+ if upnpRoot.Device.FriendlyName != "Sound Machinery" {
+ t.Errorf("expected FriendlyName 'Sound Machinery', got '%s'", upnpRoot.Device.FriendlyName)
+ }
+ if upnpRoot.Device.ModelName != "SoundTouch 10" {
+ t.Errorf("expected ModelName 'SoundTouch 10', got '%s'", upnpRoot.Device.ModelName)
+ }
+ if upnpRoot.Device.SerialNumber != "A81B6A536A09" {
+ t.Errorf("expected SerialNumber 'A81B6A536A09', got '%s'", upnpRoot.Device.SerialNumber)
+ }
+}
diff --git a/pkg/models/device.go b/pkg/models/device.go
index 2f0768e..c2ce4e4 100644
--- a/pkg/models/device.go
+++ b/pkg/models/device.go
@@ -70,6 +70,7 @@ type DiscoveredDevice struct {
// Protocol-specific details
UPnPLocation string `json:"upnp_location,omitempty"` // UPnP device description XML URL
UPnPUSN string `json:"upnp_usn,omitempty"` // UPnP Unique Service Name
+ UPnPSerial string `json:"upnp_serial,omitempty"` // Serial number from UPnP (MAC address)
MDNSHostname string `json:"mdns_hostname,omitempty"` // mDNS hostname (e.g., "device.local.")
MDNSService string `json:"mdns_service,omitempty"` // mDNS service name
ConfigName string `json:"config_name,omitempty"` // Original name from config
@@ -94,6 +95,7 @@ func (d *DiscoveredDevice) GetProtocolSpecificData() map[string]interface{} {
data["upnp"] = map[string]string{
"location": d.UPnPLocation,
"usn": d.UPnPUSN,
+ "serial": d.UPnPSerial,
}
}
diff --git a/pkg/models/models.go b/pkg/models/models.go
index 631a73a..c702928 100644
--- a/pkg/models/models.go
+++ b/pkg/models/models.go
@@ -184,6 +184,7 @@ type ServiceDeviceInfo struct {
FirmwareVersion string `json:"firmware_version" xml:"softwareVersion"`
IPAddress string `json:"ip_address" xml:"ipAddress"`
Name string `json:"name" xml:"name"`
+ MacAddress string `json:"mac_address,omitempty" xml:"-"`
DiscoveryMethod string `json:"discovery_method,omitempty"`
AccountID string `json:"account_id,omitempty"`
}
diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go
index 9873f11..d7af7e0 100644
--- a/pkg/service/datastore/datastore.go
+++ b/pkg/service/datastore/datastore.go
@@ -26,6 +26,8 @@ type DataStore struct {
DataDir string
eventMutex sync.RWMutex
deviceEvents map[string][]models.DeviceEvent
+ idMutex sync.RWMutex
+ macToSerial map[string]string
}
// NewDataStore creates a new DataStore.
@@ -38,6 +40,7 @@ func NewDataStore(dataDir string) *DataStore {
return &DataStore{
DataDir: dataDir,
deviceEvents: make(map[string][]models.DeviceEvent),
+ macToSerial: make(map[string]string),
}
}
@@ -53,6 +56,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]
+ ds.idMutex.RUnlock()
+
+ if ok {
+ device = serial
+ }
+
return filepath.Join(ds.AccountDevicesDir(account), device)
}
@@ -77,8 +88,9 @@ func (ds *DataStore) GetDeviceInfo(account, device string) (*models.ServiceDevic
SerialNumber string `xml:"serialNumber"`
} `xml:"components>component"`
NetworkInfo []struct {
- Type string `xml:"type,attr"`
- IPAddress string `xml:"ipAddress"`
+ Type string `xml:"type,attr"`
+ IPAddress string `xml:"ipAddress"`
+ MacAddress string `xml:"macAddress"`
} `xml:"networkInfo"`
}
@@ -105,6 +117,7 @@ func (ds *DataStore) GetDeviceInfo(account, device string) (*models.ServiceDevic
for _, net := range info.NetworkInfo {
if net.Type == "SCM" {
deviceInfo.IPAddress = net.IPAddress
+ deviceInfo.MacAddress = net.MacAddress
}
}
@@ -195,6 +208,10 @@ func (ds *DataStore) listDevicesInAccount(baseDir, accountName string) []models.
}
if err == nil && info != nil {
+ if info.MacAddress != "" && info.DeviceSerialNumber != "" {
+ ds.UpdateMapping(info.MacAddress, info.DeviceSerialNumber)
+ }
+
devices = append(devices, *info)
}
}
@@ -220,8 +237,9 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
SerialNumber string `xml:"serialNumber"`
} `xml:"components>component"`
NetworkInfo []struct {
- Type string `xml:"type,attr"`
- IPAddress string `xml:"ipAddress"`
+ Type string `xml:"type,attr"`
+ IPAddress string `xml:"ipAddress"`
+ MacAddress string `xml:"macAddress"`
} `xml:"networkInfo"`
DiscoveryMethod string `xml:"discoveryMethod"`
}
@@ -250,6 +268,7 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
for _, net := range info.NetworkInfo {
if net.Type == "SCM" {
deviceInfo.IPAddress = net.IPAddress
+ deviceInfo.MacAddress = net.MacAddress
}
}
@@ -633,14 +652,29 @@ func (ds *DataStore) SaveConfiguredSources(account, device string, sources []mod
return os.WriteFile(path, append(header, data...), 0644)
}
-// Initialize creates the necessary directory structure for the datastore.
+// UpdateMapping updates the mapping between MAC address and serial number.
+func (ds *DataStore) UpdateMapping(mac, serial string) {
+ if mac == "" || serial == "" {
+ return
+ }
+
+ ds.idMutex.Lock()
+ defer ds.idMutex.Unlock()
+
+ ds.macToSerial[mac] = serial
+}
+
+// Initialize creates the necessary directory structure for the datastore and populates ID mappings.
func (ds *DataStore) Initialize() error {
// Ensure base data directory exists
if err := os.MkdirAll(ds.DataDir, 0755); err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
- return nil
+ // Scan for devices to populate MAC to Serial mapping
+ _, err := ds.ListAllDevices()
+
+ return err
}
// GetETagForPresets returns the ETag (modification time) for the presets file for a specific device.
diff --git a/pkg/service/datastore/mapping_test.go b/pkg/service/datastore/mapping_test.go
new file mode 100644
index 0000000..a92f0f0
--- /dev/null
+++ b/pkg/service/datastore/mapping_test.go
@@ -0,0 +1,79 @@
+package datastore
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/gesellix/bose-soundtouch/pkg/service/constants"
+)
+
+func TestDataStore_MacAddressMapping(t *testing.T) {
+ if err := os.MkdirAll("testdata/mapping", 0755); err != nil {
+ t.Fatalf("failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll("testdata/mapping")
+
+ accountID := "12345"
+ serialNumber := "SERIAL123"
+ macAddress := "AABBCCDDEEFF"
+
+ // Create directory structure
+ deviceDir := filepath.Join("testdata/mapping", "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 := `
+
+ Test Device
+
+
+ SCM
+ 1.0
+ ` + serialNumber + `
+
+
+
+ ` + macAddress + `
+ 192.168.1.10
+
+`
+ 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 so we can verify access
+ 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)
+ }
+
+ ds := NewDataStore("testdata/mapping")
+ if err := ds.Initialize(); err != nil {
+ t.Fatalf("failed to initialize datastore: %v", err)
+ }
+
+ // Test mapping resolution in AccountDeviceDir
+ resolvedDir := ds.AccountDeviceDir(accountID, macAddress)
+ expectedDir := filepath.Join("testdata/mapping", "accounts", accountID, "devices", serialNumber)
+ if resolvedDir != expectedDir {
+ t.Errorf("expected dir %s, got %s", expectedDir, resolvedDir)
+ }
+
+ // Test that we can still use the serial number directly
+ resolvedDirSerial := ds.AccountDeviceDir(accountID, serialNumber)
+ if resolvedDirSerial != expectedDir {
+ t.Errorf("expected dir %s when using serial, got %s", expectedDir, resolvedDirSerial)
+ }
+
+ // Test GetPresets using MAC address
+ presets, err := ds.GetPresets(accountID, macAddress)
+ if err != nil {
+ t.Errorf("GetPresets failed with MAC address: %v", err)
+ }
+ if len(presets) == 0 {
+ t.Error("expected presets to be loaded")
+ }
+}