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") + } +}