fix(datastore): resolve local data directory using MAC address mapping

Fixes an issue where device data (e.g., Presets.xml) could not be located when accessed via MAC address because the internal directory structure is organized by serial number.

- Add a `macToSerial` mapping in `DataStore` to bridge MAC addresses from API requests to internal serial-numbered directories.
- Implement automatic mapping population during `DataStore` initialization by scanning `DeviceInfo.xml` files.
- Update `AccountDeviceDir` to transparently resolve MAC addresses to serial numbers for file path construction.
- Enhance UPnP discovery to capture the MAC address (as `serialNumber` in the device description) for better device identification.
- Include automated tests for MAC-to-serial resolution and UPnP enrichment.
This commit is contained in:
Tobias Gesellchen
2026-02-22 23:40:51 +01:00
parent 9ee1c96477
commit 403e2275dc
6 changed files with 251 additions and 9 deletions
+41 -3
View File
@@ -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
}
+88
View File
@@ -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 := `<?xml version="1.0" encoding="utf-8"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<friendlyName>Sound Machinery</friendlyName>
<modelName>SoundTouch 10</modelName>
<serialNumber>A81B6A536A09</serialNumber>
</device>
</root>`
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 := `<?xml version="1.0" encoding="utf-8"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<device>
<friendlyName>Sound Machinery</friendlyName>
<modelName>SoundTouch 10</modelName>
<serialNumber>A81B6A536A09</serialNumber>
</device>
</root>`
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)
}
}
+2
View File
@@ -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,
}
}
+1
View File
@@ -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"`
}
+40 -6
View File
@@ -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.
+79
View File
@@ -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 := `<?xml version="1.0" encoding="UTF-8"?>
<info deviceID="` + serialNumber + `">
<name>Test Device</name>
<components>
<component>
<componentCategory>SCM</componentCategory>
<softwareVersion>1.0</softwareVersion>
<serialNumber>` + serialNumber + `</serialNumber>
</component>
</components>
<networkInfo type="SCM">
<macAddress>` + macAddress + `</macAddress>
<ipAddress>192.168.1.10</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 so we can verify access
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)
}
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")
}
}