From 505e6dd760dee4468eb020486fa8f1a9244941a6 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 15 Feb 2026 15:32:28 +0100 Subject: [PATCH] Refactor data storage to use account-based hierarchy and update Web UI --- README.md | 36 ++++----- data/.gitignore | 2 + data/patterns.json | 17 ----- pkg/service/datastore/datastore.go | 25 ++----- pkg/service/datastore/datastore_test.go | 15 ++-- pkg/service/handlers/handlers_etag_test.go | 2 +- pkg/service/handlers/handlers_marge_test.go | 10 +-- pkg/service/handlers/handlers_setup.go | 5 +- pkg/service/handlers/handlers_setup_test.go | 2 +- pkg/service/handlers/server.go | 69 +++++++++++++----- pkg/service/handlers/server_merge_test.go | 18 ++++- pkg/service/handlers/web/index.html | 8 +- pkg/service/handlers/web/js/script.js | 21 ++++-- pkg/service/proxy/patterns.go | 29 ++++++-- pkg/service/proxy/patterns_test.go | 61 ++++++++++++++++ pkg/service/proxy/recorder_test.go | 42 +++++++---- pkg/service/setup/setup.go | 81 +++++++++++++++++---- pkg/service/setup/setup_test.go | 5 +- 18 files changed, 310 insertions(+), 138 deletions(-) delete mode 100644 data/patterns.json create mode 100644 pkg/service/proxy/patterns_test.go diff --git a/README.md b/README.md index 8e2120c..f359d82 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ soundtouch-cli --host 192.168.1.100 volume set --level 50 soundtouch-cli --host 192.168.1.100 preset list ``` -For full CLI documentation, see the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.md). +For full CLI documentation, see the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html). ### SoundTouch Service (Cloud Shutdown Protection) @@ -82,11 +82,11 @@ soundtouch-service ``` Open `http://localhost:8000` in your browser to manage your devices. Documentation is also available directly through the web interface. -For a comprehensive guide on transitioning your system, see the [Bose Cloud Shutdown: Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.md). +For a comprehensive guide on transitioning your system, see the [Bose Cloud Shutdown: Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html). -Detailed service configuration and Docker instructions can be found in [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SOUNDTOUCH-SERVICE.md). +Detailed service configuration and Docker instructions can be found in [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SOUNDTOUCH-SERVICE.html). -For professional migration tips and safety measures, see the [Migration & Safety Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.md). +For professional migration tips and safety measures, see the [Migration & Safety Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html). ### Library Usage @@ -379,19 +379,19 @@ This library supports all Bose SoundTouch-compatible devices, including: ## Documentation - 📖 [Contributing Guide](CONTRIBUTING.md) - How to contribute to the project -- 📚 [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API-ENDPOINTS.md) - Complete endpoint documentation -- 🔧 [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.md) - Command-line tool guide -- 🌐 [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SOUNDTOUCH-SERVICE.md) - Local service setup and migration -- 🎯 [Getting Started](https://gesellix.github.io/Bose-SoundTouch/guides/GETTING-STARTED.md) - Detailed setup and usage + - 📚 [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API-ENDPOINTS.html) - Complete endpoint documentation +- 🔧 [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html) - Command-line tool guide +- 🌐 [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SOUNDTOUCH-SERVICE.html) - Local service setup and migration +- 🎯 [Getting Started](https://gesellix.github.io/Bose-SoundTouch/guides/GETTING-STARTED.html) - Detailed setup and usage - 📻 [Preset Quick Start](https://gesellix.github.io/Bose-SoundTouch/PRESET-QUICKSTART.md) - Favorite content management - 🧭 [Navigation Guide](https://gesellix.github.io/Bose-SoundTouch/NAVIGATION-GUIDE.md) - Content browsing and station management - 📋 [Navigation API Reference](https://gesellix.github.io/Bose-SoundTouch/API-NAVIGATION-REFERENCE.md) - Navigation API documentation -- ⚙️ [Advanced Features](https://gesellix.github.io/Bose-SoundTouch/reference/SYSTEM-ENDPOINTS.md) - Advanced functionality -- 🏠 [Multiroom Setup](https://gesellix.github.io/Bose-SoundTouch/reference/ZONE-MANAGEMENT.md) - Zone configuration guide -- ⚡ [WebSocket Events](https://gesellix.github.io/Bose-SoundTouch/reference/WEBSOCKET-EVENTS.md) - Real-time event handling -- 🔔 [Speaker Notifications](https://gesellix.github.io/Bose-SoundTouch/reference/SPEAKER-ENDPOINT.md) - TTS and audio notifications guide -- 🔍 [Device Discovery](https://gesellix.github.io/Bose-SoundTouch/reference/DISCOVERY.md) - Discovery configuration -- 🛠️ [Troubleshooting](https://gesellix.github.io/Bose-SoundTouch/guides/TROUBLESHOOTING.md) - Common issues and solutions +- ⚙️ [Advanced Features](https://gesellix.github.io/Bose-SoundTouch/reference/SYSTEM-ENDPOINTS.html) - Advanced functionality +- 🏠 [Multiroom Setup](https://gesellix.github.io/Bose-SoundTouch/reference/ZONE-MANAGEMENT.html) - Zone configuration guide +- ⚡ [WebSocket Events](https://gesellix.github.io/Bose-SoundTouch/reference/WEBSOCKET-EVENTS.html) - Real-time event handling +- 🔔 [Speaker Notifications](https://gesellix.github.io/Bose-SoundTouch/reference/SPEAKER-ENDPOINT.html) - TTS and audio notifications guide +- 🔍 [Device Discovery](https://gesellix.github.io/Bose-SoundTouch/reference/DISCOVERY.html) - Discovery configuration +- 🛠️ [Troubleshooting](https://gesellix.github.io/Bose-SoundTouch/guides/TROUBLESHOOTING.html) - Common issues and solutions ## Development @@ -528,11 +528,11 @@ If you discover new endpoints, features, or improvements through this library, p - ❓ **Questions**: Check [existing discussions](https://github.com/gesellix/bose-soundtouch/discussions) - 📖 **Documentation**: [Online Documentation](https://gesellix.github.io/Bose-SoundTouch/) - 🔍 **New Discoveries**: [Undocumented Community Features](https://gesellix.github.io/Bose-SoundTouch/UNDOCUMENTED-COMMUNITY-FEATURES.md) -- 🌐 **Upstream Analysis**: [Upstream URLs & Domains](https://gesellix.github.io/Bose-SoundTouch/analysis/UPSTREAM-URLS.md) -- 🔧 **Redirection Guide**: [Device Redirect Methods](https://gesellix.github.io/Bose-SoundTouch/analysis/DEVICE-REDIRECT-METHODS.md) -- 🐣 **Initial Setup**: [Device Initial Setup Variants](https://gesellix.github.io/Bose-SoundTouch/guides/DEVICE-INITIAL-SETUP.md) +- 🌐 **Upstream Analysis**: [Upstream URLs & Domains](https://gesellix.github.io/Bose-SoundTouch/analysis/UPSTREAM-URLS.html) +- 🔧 **Redirection Guide**: [Device Redirect Methods](https://gesellix.github.io/Bose-SoundTouch/analysis/DEVICE-REDIRECT-METHODS.html) +- 🐣 **Initial Setup**: [Device Initial Setup Variants](https://gesellix.github.io/Bose-SoundTouch/guides/DEVICE-INITIAL-SETUP.html) - 📜 **Logging & Debugging**: [Device Logging Guide](https://gesellix.github.io/Bose-SoundTouch/DEVICE-LOGGING.md) -- 🔒 **HTTPS & CA Setup**: [HTTPS & Custom CA Guide](https://gesellix.github.io/Bose-SoundTouch/guides/HTTPS-SETUP.md) +- 🔒 **HTTPS & CA Setup**: [HTTPS & Custom CA Guide](https://gesellix.github.io/Bose-SoundTouch/guides/HTTPS-SETUP.html) --- diff --git a/data/.gitignore b/data/.gitignore index 0377eb8..ce3800f 100644 --- a/data/.gitignore +++ b/data/.gitignore @@ -1,4 +1,6 @@ +accounts/ certs/ default/ interactions/ +patterns.json settings.json diff --git a/data/patterns.json b/data/patterns.json deleted file mode 100644 index db6eeb1..0000000 --- a/data/patterns.json +++ /dev/null @@ -1,17 +0,0 @@ -[ - { - "name": "IPv4", - "regexp": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$", - "replacement": "{ip}" - }, - { - "name": "DeviceID", - "regexp": "^[A-F0-9]{12}$", - "replacement": "{deviceId}" - }, - { - "name": "AccountID", - "regexp": "^\\d{1,10}$", - "replacement": "{accountId}" - } -] diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index bd1efc7..cce897d 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -42,12 +42,12 @@ func NewDataStore(dataDir string) *DataStore { // AccountDir returns the directory path for a specific account. func (ds *DataStore) AccountDir(account string) string { - return filepath.Join(ds.DataDir, account) + return filepath.Join(ds.DataDir, "accounts", account) } // AccountDevicesDir returns the devices directory path for a specific account. func (ds *DataStore) AccountDevicesDir(account string) string { - return filepath.Join(ds.DataDir, account, constants.DevicesDir) + return filepath.Join(ds.AccountDir(account), constants.DevicesDir) } // AccountDeviceDir returns the directory path for a specific device within an account. @@ -154,13 +154,13 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) { func (ds *DataStore) getPossibleDataDirs() []string { dirs := []string{} - if exists(ds.DataDir) { - dirs = append(dirs, ds.DataDir) + if exists(filepath.Join(ds.DataDir, "accounts")) { + dirs = append(dirs, filepath.Join(ds.DataDir, "accounts")) } - // Also check soundcork-go/data if it's different and exists - altDir := "soundcork-go/data" - if ds.DataDir != altDir && exists(altDir) { + // Also check soundcork-go/data/accounts if it's different and exists + altDir := "soundcork-go/data/accounts" + if filepath.Join(ds.DataDir, "accounts") != altDir && exists(altDir) { dirs = append(dirs, altDir) } @@ -639,17 +639,6 @@ func (ds *DataStore) Initialize() error { return fmt.Errorf("failed to create data directory: %w", err) } - // Ensure default account exists - defaultDir := ds.AccountDir("default") - if err := os.MkdirAll(defaultDir, 0755); err != nil { - return fmt.Errorf("failed to create default account directory: %w", err) - } - - // Ensure devices subdirectory for default account - if err := os.MkdirAll(ds.AccountDevicesDir("default"), 0755); err != nil { - return fmt.Errorf("failed to create default devices directory: %w", err) - } - return nil } diff --git a/pkg/service/datastore/datastore_test.go b/pkg/service/datastore/datastore_test.go index 68178be..b99d845 100644 --- a/pkg/service/datastore/datastore_test.go +++ b/pkg/service/datastore/datastore_test.go @@ -22,8 +22,9 @@ func TestDataStore(t *testing.T) { // Test Save/Get DeviceInfo info := &models.ServiceDeviceInfo{ - DeviceID: device, - Name: "Test Speaker", + DeviceID: device, + Name: "Test Speaker", + AccountID: account, } err = ds.SaveDeviceInfo(account, device, info) @@ -87,7 +88,7 @@ func TestDataStore(t *testing.T) { } // Test path helpers - expectedAccountDir := filepath.Join(tempDir, account) + expectedAccountDir := filepath.Join(tempDir, "accounts", account) if ds.AccountDir(account) != expectedAccountDir { t.Errorf("Expected account dir %s, got %s", expectedAccountDir, ds.AccountDir(account)) } @@ -151,6 +152,7 @@ func TestListAllDevices(t *testing.T) { DeviceSerialNumber: deviceID, ProductCode: "SoundTouch 10", FirmwareVersion: "1.2.3", + AccountID: account, } err = ds.SaveDeviceInfo(account, deviceID, info) @@ -185,8 +187,9 @@ func TestListAllDevices_EmptyDeviceID(t *testing.T) { deviceID := "" info := &models.ServiceDeviceInfo{ - DeviceID: deviceID, - Name: "Empty ID Speaker", + DeviceID: deviceID, + Name: "Empty ID Speaker", + AccountID: account, } // Use IP as fallback for device ID if it is empty @@ -230,11 +233,13 @@ func TestListAllDevices_MultipleEmptyIDs(t *testing.T) { DeviceID: "", Name: "Speaker 1", IPAddress: "192.168.1.1", + AccountID: account, } info2 := &models.ServiceDeviceInfo{ DeviceID: "", Name: "Speaker 2", IPAddress: "192.168.1.2", + AccountID: account, } // We use the same logic as in main.go: use IP as fallback for directory name diff --git a/pkg/service/handlers/handlers_etag_test.go b/pkg/service/handlers/handlers_etag_test.go index b50e7e9..936f00f 100644 --- a/pkg/service/handlers/handlers_etag_test.go +++ b/pkg/service/handlers/handlers_etag_test.go @@ -24,7 +24,7 @@ func TestMargeETags(t *testing.T) { account := "12345" deviceID := "DEV1" - accountDir := filepath.Join(tempDir, account) + accountDir := filepath.Join(tempDir, "accounts", account) deviceDir := filepath.Join(accountDir, "devices", deviceID) _ = os.MkdirAll(deviceDir, 0755) diff --git a/pkg/service/handlers/handlers_marge_test.go b/pkg/service/handlers/handlers_marge_test.go index 76aefd1..bfb1fcb 100644 --- a/pkg/service/handlers/handlers_marge_test.go +++ b/pkg/service/handlers/handlers_marge_test.go @@ -72,7 +72,7 @@ func TestMargeAccountFull(t *testing.T) { account := "12345" deviceID := "ABCDE" - accountDir := filepath.Join(tempDir, account) + accountDir := filepath.Join(tempDir, "accounts", account) deviceDir := filepath.Join(accountDir, "devices", deviceID) err = os.MkdirAll(deviceDir, 0755) @@ -137,7 +137,7 @@ func TestMargePresets(t *testing.T) { account := "12345" deviceID := "any" - accountDir := filepath.Join(tempDir, account) + accountDir := filepath.Join(tempDir, "accounts", account) deviceDir := filepath.Join(accountDir, "devices", deviceID) err = os.MkdirAll(deviceDir, 0755) @@ -208,7 +208,7 @@ func TestMargeUpdatePreset(t *testing.T) { account := "12345" deviceID := "DEV1" - accountDir := filepath.Join(tempDir, account) + accountDir := filepath.Join(tempDir, "accounts", account) deviceDir := filepath.Join(accountDir, "devices", deviceID) err = os.MkdirAll(deviceDir, 0755) @@ -277,7 +277,7 @@ func TestMargeDeviceInfo(t *testing.T) { account := "12345" deviceID := "DEV1" - accountDir := filepath.Join(tempDir, account) + accountDir := filepath.Join(tempDir, "accounts", account) deviceDir := filepath.Join(accountDir, "devices", deviceID) err = os.MkdirAll(deviceDir, 0755) @@ -343,7 +343,7 @@ func TestMargeAddRemoveDevice(t *testing.T) { account := "12345" - accountDir := filepath.Join(tempDir, account) + accountDir := filepath.Join(tempDir, "accounts", account) err = os.MkdirAll(accountDir, 0755) if err != nil { diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go index ec0d751..c7630e9 100644 --- a/pkg/service/handlers/handlers_setup.go +++ b/pkg/service/handlers/handlers_setup.go @@ -101,10 +101,7 @@ func (s *Server) HandleRemoveDevice(w http.ResponseWriter, r *http.Request) { return } - // For now we assume a default account if not specified, - // or we might need to find which account this device belongs to. - // Looking at DataStore.ListAllDevices, it returns models.ServiceDeviceInfo which has DeviceID. - + // Find which account this device belongs to. devices, err := s.ds.ListAllDevices() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/pkg/service/handlers/handlers_setup_test.go b/pkg/service/handlers/handlers_setup_test.go index c9d6534..56ef5d0 100644 --- a/pkg/service/handlers/handlers_setup_test.go +++ b/pkg/service/handlers/handlers_setup_test.go @@ -262,7 +262,7 @@ func TestRemoveDevice(t *testing.T) { // Setup a dummy device in the datastore account := "test-account" deviceID := "TEST-DEVICE-ID" - deviceDir := filepath.Join(tempDir, account, "devices", deviceID) + deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID) if err := os.MkdirAll(deviceDir, 0755); err != nil { t.Fatalf("Failed to create device dir: %v", err) } diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index a987327..8940b7e 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -186,8 +186,33 @@ func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) { deviceID = d.Host } + accountID := "" + + if liveInfo, err := s.sm.GetLiveDeviceInfo(d.Host); err == nil { + if liveInfo.MargeAccountUUID != "" { + accountID = liveInfo.MargeAccountUUID + } + + if liveInfo.SerialNumber != "" { + d.SerialNo = liveInfo.SerialNumber + deviceID = d.SerialNo + } + } + + if accountID == "" { + // Try to find account ID from existing device entries if live info failed + if existing := s.findExistingDeviceInfo(d); existing != nil { + accountID = existing.AccountID + } + } + + if accountID == "" { + accountID = "default" + } + info := &models.ServiceDeviceInfo{ - DeviceID: d.SerialNo, + DeviceID: deviceID, + AccountID: accountID, Name: d.Name, IPAddress: d.Host, DeviceSerialNumber: d.SerialNo, @@ -199,10 +224,10 @@ func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) { // If we had an IP-based entry and now have a Serial, clean up the IP-based entry if d.SerialNo != "" && existingID != "" && existingID != d.SerialNo { log.Printf("Device %s previously known as %s, migrating to serial-based ID %s", d.Name, existingID, d.SerialNo) - _ = s.ds.RemoveDevice("default", existingID) + _ = s.ds.RemoveDevice(accountID, existingID) } - if err := s.ds.SaveDeviceInfo("default", deviceID, info); err != nil { + if err := s.ds.SaveDeviceInfo(accountID, deviceID, info); err != nil { log.Printf("Failed to save device info: %v", err) } } @@ -233,15 +258,24 @@ func (s *Server) mergeOverlappingDevices() { var master *models.ServiceDeviceInfo for i := range devices { - if devices[i].DeviceSerialNumber != "" || devices[i].DeviceID != "" { + if devices[i].DeviceSerialNumber != "" { master = &devices[i] break } } if master == nil { - // None have serials, just keep the first one (already handled by ListAllDevices unique check usually, - // but ListAllDevices might see different AccountIDs or directories) + // Fallback: look for one with DeviceID that isn't the IP + for i := range devices { + if devices[i].DeviceID != "" && devices[i].DeviceID != devices[i].IPAddress { + master = &devices[i] + break + } + } + } + + if master == nil { + // None have serials, just keep the first one continue } @@ -267,26 +301,27 @@ func (s *Server) mergeOverlappingDevices() { } func (s *Server) findExistingDeviceID(d models.DiscoveredDevice) string { + info := s.findExistingDeviceInfo(d) + if info != nil { + return info.DeviceID + } + + return "" +} + +func (s *Server) findExistingDeviceInfo(d models.DiscoveredDevice) *models.ServiceDeviceInfo { allDevices, _ := s.ds.ListAllDevices() for i := range allDevices { known := allDevices[i] // Match by Serial if d.SerialNo != "" && (known.DeviceID == d.SerialNo || known.DeviceSerialNumber == d.SerialNo) { - if known.DeviceID != "" { - return known.DeviceID - } - - return known.IPAddress + return &known } // Match by IP if d.Host != "" && known.IPAddress == d.Host { - if known.DeviceID != "" { - return known.DeviceID - } - - return known.IPAddress + return &known } } - return "" + return nil } diff --git a/pkg/service/handlers/server_merge_test.go b/pkg/service/handlers/server_merge_test.go index 53fd913..25742bc 100644 --- a/pkg/service/handlers/server_merge_test.go +++ b/pkg/service/handlers/server_merge_test.go @@ -24,8 +24,11 @@ func TestMergeOverlappingDevices(t *testing.T) { // 1. Save IP-based entry infoIP := &models.ServiceDeviceInfo{ - Name: "Speaker IP", - IPAddress: ip, + DeviceID: ip, + Name: "Speaker IP", + IPAddress: ip, + AccountID: "default", + ProductCode: "ST10", } err = ds.SaveDeviceInfo("default", ip, infoIP) if err != nil { @@ -38,6 +41,8 @@ func TestMergeOverlappingDevices(t *testing.T) { DeviceSerialNumber: serial, Name: "Speaker Serial", IPAddress: ip, + AccountID: "default", + ProductCode: "ST10", } err = ds.SaveDeviceInfo("default", serial, infoSerial) if err != nil { @@ -76,8 +81,11 @@ func TestFindExistingDeviceID(t *testing.T) { // Save IP-based ds.SaveDeviceInfo("default", ip, &models.ServiceDeviceInfo{ - IPAddress: ip, - Name: "IP Speaker", + DeviceID: ip, + IPAddress: ip, + Name: "IP Speaker", + AccountID: "default", + ProductCode: "ST10", }) // Test finding by IP @@ -94,6 +102,8 @@ func TestFindExistingDeviceID(t *testing.T) { DeviceSerialNumber: serial, IPAddress: ip, Name: "Serial Speaker", + AccountID: "default", + ProductCode: "ST10", }) // Test finding by IP should now return Serial (if Serial is known) diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html index 3f242c0..edcdcea 100644 --- a/pkg/service/handlers/web/index.html +++ b/pkg/service/handlers/web/index.html @@ -60,14 +60,14 @@
⚠️ Safety First: Before starting any migration, please read our - Professional Migration & Safety Guide. + Professional Migration & Safety Guide. The toolkit automatically creates backups, but understanding the process is key to a smooth transition.

Useful Links

@@ -87,7 +87,7 @@
- (Upstream proxy URL) + (Upstream proxy URL - usually the same as Target Domain)
diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/js/script.js index c5e1191..34a3a48 100644 --- a/pkg/service/handlers/web/js/script.js +++ b/pkg/service/handlers/web/js/script.js @@ -92,7 +92,7 @@ async function fetchDevices() { if (devices.length === 0) { container.innerHTML = 'No devices known yet.'; } else { - let html = ''; + let html = '
NameIP AddressModelSerial NumberFirmwareMethodAction
'; // Clear and repopulate selectors const currentSyncVal = syncSelector.value; @@ -104,11 +104,10 @@ async function fetchDevices() { const methodLabel = d.discovery_method === 'manual' ? '👤 Manual' : '🔍 Auto'; html += ` - + - - - + +
Name & ModelIP AddressDevice & Account IDFirmware & SerialMethodAction
${d.name}
${d.name}
${d.product_code}
${d.ip_address}${d.product_code}${d.device_serial_number}${d.firmware_version || '0.0.0'}
${d.device_id}
${d.account_id || 'default'}
${d.firmware_version || '0.0.0'}
${d.device_serial_number}
${methodLabel} @@ -336,6 +335,12 @@ async function updateDeviceInfo(ip) { const firmwareEl = row.querySelector('.col-firmware'); if (firmwareEl && info.softwareVersion) firmwareEl.innerText = info.softwareVersion; + + const deviceIdEl = row.querySelector('.col-deviceid'); + if (deviceIdEl && info.deviceID) deviceIdEl.innerText = info.deviceID; + + const accountIdEl = row.querySelector('.col-accountid'); + if (accountIdEl && info.margeAccountUUID) accountIdEl.innerText = info.margeAccountUUID; } } catch (error) { console.warn('Failed to fetch live info for ' + ip, error); @@ -396,6 +401,12 @@ async function showSummary(ip) { const firmwareEl = row.querySelector('.col-firmware'); if (firmwareEl && summary.firmware_version) firmwareEl.innerText = summary.firmware_version; + + const deviceIdEl = row.querySelector('.col-deviceid'); + if (deviceIdEl && summary.device_id) deviceIdEl.innerText = summary.device_id; + + const accountIdEl = row.querySelector('.col-accountid'); + if (accountIdEl && summary.account_id) accountIdEl.innerText = summary.account_id; } document.getElementById('ssh-status').innerText = summary.ssh_success ? '✅ Success' : '❌ Failed'; diff --git a/pkg/service/proxy/patterns.go b/pkg/service/proxy/patterns.go index c525c46..b8cc819 100644 --- a/pkg/service/proxy/patterns.go +++ b/pkg/service/proxy/patterns.go @@ -59,13 +59,26 @@ func (pp PathPatterns) Sanitize(segment string) (string, string) { // DefaultPatterns returns the default set of path patterns. func DefaultPatterns() PathPatterns { - p := PathPattern{ - Name: "IPv4", - Regexp: `^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$`, - Replacement: "{ip}", + return PathPatterns{ + { + Name: "IPv4", + Regexp: `^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$`, + Replacement: "{ip}", + }, + { + Name: "UUID", + Regexp: `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`, + Replacement: "{uuid}", + }, + { + Name: "AccountID", + Regexp: `^\d{5,10}$`, + Replacement: "{accountId}", + }, + { + Name: "DeviceID", + Regexp: `^[0-9a-fA-F]{12}$`, + Replacement: "{device_id}", + }, } - re, _ := regexp.Compile(p.Regexp) - p.compiled = re - - return PathPatterns{p} } diff --git a/pkg/service/proxy/patterns_test.go b/pkg/service/proxy/patterns_test.go new file mode 100644 index 0000000..91c43bb --- /dev/null +++ b/pkg/service/proxy/patterns_test.go @@ -0,0 +1,61 @@ +package proxy + +import ( + "regexp" + "testing" +) + +func TestDefaultPatterns(t *testing.T) { + patterns := DefaultPatterns() + if len(patterns) != 4 { + t.Errorf("Expected 4 default patterns, got %d", len(patterns)) + } + + expectedNames := []string{"IPv4", "UUID", "AccountID", "DeviceID"} + for i, name := range expectedNames { + if patterns[i].Name != name { + t.Errorf("Expected pattern %d name %s, got %s", i, name, patterns[i].Name) + } + } +} + +func TestPathPatterns_Sanitize(t *testing.T) { + patterns := DefaultPatterns() + // Need to compile them as DefaultPatterns() in its new form doesn't compile them (main.go or LoadPatterns does it) + // Wait, actually the new DefaultPatterns() I wrote doesn't compile them. + // But PathPatterns.Sanitize checks for compiled != nil. + + // Let's manually compile for the test + for i := range patterns { + patterns[i].compiled = mustCompile(patterns[i].Regexp) + } + + tests := []struct { + segment string + wantRepl string + }{ + {"192.168.1.100", "{ip}"}, + {"1234567", "{accountId}"}, + {"12345", "{accountId}"}, + {"12345678-1234-5678-9012-123456789012", "{uuid}"}, + {"D05FB8A848E5", "{device_id}"}, + {"some-other-segment", ""}, + } + + for _, tt := range tests { + repl, _ := patterns.Sanitize(tt.segment) + if tt.wantRepl == "" { + if repl != tt.segment { + t.Errorf("Sanitize(%q) = %q, want %q (no change)", tt.segment, repl, tt.segment) + } + } else { + if repl != tt.wantRepl { + t.Errorf("Sanitize(%q) = %q, want %q", tt.segment, repl, tt.wantRepl) + } + } + } +} + +func mustCompile(re string) *regexp.Regexp { + return regexp.MustCompile(re) +} diff --git a/pkg/service/proxy/recorder_test.go b/pkg/service/proxy/recorder_test.go index 29e056c..0939f50 100644 --- a/pkg/service/proxy/recorder_test.go +++ b/pkg/service/proxy/recorder_test.go @@ -19,6 +19,11 @@ func TestRecorder_Record_Structure(t *testing.T) { defer os.RemoveAll(tmpDir) r := NewRecorder(tmpDir) + // Compile default patterns + for i := range r.Patterns { + re, _ := regexp.Compile(r.Patterns[i].Regexp) + r.Patterns[i].compiled = re + } tests := []struct { name string @@ -100,11 +105,11 @@ func TestRecorder_Record_Sanitization(t *testing.T) { r := NewRecorder(tmpDir) // Add a custom pattern r.Patterns = append(r.Patterns, PathPattern{ - Name: "DeviceID", + Name: "CustomDeviceID", Regexp: `^A81B\w{8}$`, Replacement: "{deviceId}", }) - // Re-compile + // Compile all patterns for i := range r.Patterns { re, _ := regexp.Compile(r.Patterns[i].Regexp) r.Patterns[i].compiled = re @@ -124,7 +129,7 @@ func TestRecorder_Record_Sanitization(t *testing.T) { t.Fatalf("Record failed: %v", err) } - expectedDir := filepath.Join(tmpDir, "interactions", r.SessionID, "self", "info", "{ip}", "{deviceId}") + expectedDir := filepath.Join(tmpDir, "interactions", r.SessionID, "self", "info", "{ip}", "{device_id}") if _, err := os.Stat(expectedDir); os.IsNotExist(err) { t.Errorf("Expected directory %s does not exist", expectedDir) } @@ -137,13 +142,13 @@ func TestRecorder_Record_Sanitization(t *testing.T) { content, _ := os.ReadFile(filepath.Join(expectedDir, files[0].Name())) contentStr := string(content) - if !strings.Contains(contentStr, "### GET /info/{{ip}}/{{deviceId}}") { + if !strings.Contains(contentStr, "### GET /info/{{ip}}/{{device_id}}") { t.Errorf("Expected sanitized comment in .http file, got:\n%s", contentStr) } - if !strings.Contains(contentStr, "GET /info/{{ip}}/{{deviceId}}") { + if !strings.Contains(contentStr, "GET /info/{{ip}}/{{device_id}}") { t.Errorf("Expected sanitized URL in .http file, got:\n%s", contentStr) } - if !strings.Contains(contentStr, "X-Device: {{deviceId}}") { + if !strings.Contains(contentStr, "X-Device: {{device_id}}") { t.Errorf("Expected sanitized Header in .http file, got:\n%s", contentStr) } } @@ -156,22 +161,19 @@ func TestRecorder_Record_Sanitization_Account(t *testing.T) { defer os.RemoveAll(tmpDir) r := NewRecorder(tmpDir) - // Add AccountID pattern - r.Patterns = append(r.Patterns, PathPattern{ - Name: "AccountID", - Regexp: `^\d{1,10}$`, - Replacement: "{accountId}", - }) - // Re-compile + // Use default patterns which now include AccountID + r.Patterns = DefaultPatterns() + // Compile all patterns for i := range r.Patterns { re, _ := regexp.Compile(r.Patterns[i].Regexp) r.Patterns[i].compiled = re } + accountID := "1234567" req := &http.Request{ Method: "GET", URL: &url.URL{ - Path: "/marge/accounts/12345/full", + Path: "/marge/accounts/" + accountID + "/full", }, Header: make(http.Header), } @@ -200,7 +202,7 @@ func TestRecorder_Record_Sanitization_Account(t *testing.T) { if !strings.Contains(contentStr, "GET /marge/accounts/{{accountId}}/full") { t.Errorf("Expected sanitized URL in .http file, got:\n%s", contentStr) } - if !strings.Contains(contentStr, "// accountId: 12345") { + if !strings.Contains(contentStr, "// accountId: "+accountID) { t.Errorf("Expected accountId comment in .http file, got:\n%s", contentStr) } } @@ -262,6 +264,11 @@ func TestRecorder_IncreasingPrefix(t *testing.T) { defer os.RemoveAll(tmpDir) r := NewRecorder(tmpDir) + // Compile default patterns + for i := range r.Patterns { + re, _ := regexp.Compile(r.Patterns[i].Regexp) + r.Patterns[i].compiled = re + } req := &http.Request{ Method: "GET", URL: &url.URL{ @@ -299,6 +306,11 @@ func TestRecorder_EnvFile(t *testing.T) { defer os.RemoveAll(tmpDir) r := NewRecorder(tmpDir) + // Compile default patterns + for i := range r.Patterns { + re, _ := regexp.Compile(r.Patterns[i].Regexp) + r.Patterns[i].compiled = re + } req := &http.Request{ Method: "GET", URL: &url.URL{ diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go index 38f8181..f98e4a3 100644 --- a/pkg/service/setup/setup.go +++ b/pkg/service/setup/setup.go @@ -59,6 +59,8 @@ type MigrationSummary struct { DeviceName string `json:"device_name,omitempty"` DeviceModel string `json:"device_model,omitempty"` DeviceSerial string `json:"device_serial,omitempty"` + DeviceID string `json:"device_id,omitempty"` + AccountID string `json:"account_id,omitempty"` FirmwareVersion string `json:"firmware_version,omitempty"` CACertTrusted bool `json:"ca_cert_trusted"` ServerHTTPSURL string `json:"server_https_url,omitempty"` @@ -92,14 +94,15 @@ func NewManager(serverURL string, ds *datastore.DataStore, cm *certmanager.Certi // DeviceInfoXML represents the XML structure from :8090/info type DeviceInfoXML struct { - XMLName xml.Name `xml:"info" json:"-"` - DeviceID string `xml:"deviceID,attr" json:"deviceID"` - Name string `xml:"name" json:"name"` - Type string `xml:"type" json:"type"` - MaccAddress string `xml:"maccAddress" json:"maccAddress"` - SoftwareVer string `xml:"-" json:"softwareVersion"` - SerialNumber string `xml:"-" json:"serialNumber"` - Components []struct { + XMLName xml.Name `xml:"info" json:"-"` + DeviceID string `xml:"deviceID,attr" json:"deviceID"` + Name string `xml:"name" json:"name"` + Type string `xml:"type" json:"type"` + MaccAddress string `xml:"maccAddress" json:"maccAddress"` + SoftwareVer string `xml:"-" json:"softwareVersion"` + SerialNumber string `xml:"-" json:"serialNumber"` + MargeAccountUUID string `xml:"margeAccountUUID" json:"margeAccountUUID"` + Components []struct { Category string `xml:"componentCategory"` SoftwareVersion string `xml:"softwareVersion"` SerialNumber string `xml:"serialNumber"` @@ -260,6 +263,8 @@ func (m *Manager) populateDeviceInfo(summary *MigrationSummary, deviceIP string) summary.DeviceName = d.Name summary.DeviceModel = d.ProductCode summary.DeviceSerial = d.DeviceSerialNumber + summary.DeviceID = d.DeviceID + summary.AccountID = d.AccountID summary.FirmwareVersion = d.FirmwareVersion break @@ -284,6 +289,14 @@ func (m *Manager) populateDeviceInfo(summary *MigrationSummary, deviceIP string) if infoXML.SoftwareVer != "" { summary.FirmwareVersion = infoXML.SoftwareVer } + + if infoXML.DeviceID != "" { + summary.DeviceID = infoXML.DeviceID + } + + if infoXML.MargeAccountUUID != "" { + summary.AccountID = infoXML.MargeAccountUUID + } } } @@ -557,18 +570,39 @@ func (m *Manager) BackupConfigOffDevice(deviceIP string) error { client := m.NewSSH(deviceIP) - // We need the serial number to find the right directory in DataStore + // We need the serial number and account identifier to find the right directory in DataStore info, err := m.GetLiveDeviceInfo(deviceIP) if err != nil { return fmt.Errorf("failed to get device info: %w", err) } - serial := info.SerialNumber - if serial == "" { - return fmt.Errorf("could not determine device serial number") + accountID := info.MargeAccountUUID + deviceID := info.SerialNumber + + if deviceID == "" { + deviceID = info.DeviceID } - deviceDir := m.DataStore.AccountDeviceDir("default", serial) + if deviceID == "" { + deviceID = deviceIP + } + + if accountID == "" { + // Try to find account ID from existing device entries if info didn't have it + devices, _ := m.DataStore.ListAllDevices() + for i := range devices { + if devices[i].DeviceSerialNumber == info.SerialNumber || (info.DeviceID != "" && devices[i].DeviceID == info.DeviceID) { + accountID = devices[i].AccountID + break + } + } + } + + if accountID == "" { + accountID = "default" + } + + deviceDir := m.DataStore.AccountDeviceDir(accountID, deviceID) if err := os.MkdirAll(deviceDir, 0755); err != nil { return fmt.Errorf("failed to create device directory: %w", err) } @@ -1207,13 +1241,32 @@ func (m *Manager) SyncDeviceData(deviceIP string) error { return fmt.Errorf("failed to get device info: %w", err) } - accountID := "default" + accountID := "" deviceID := info.SerialNumber if deviceID == "" { deviceID = deviceIP } + if info.MargeAccountUUID != "" { + accountID = info.MargeAccountUUID + } + + if accountID == "" { + // Try to find account ID from existing device entries if info didn't have it + devices, _ := m.DataStore.ListAllDevices() + for i := range devices { + if devices[i].DeviceSerialNumber == info.SerialNumber || devices[i].DeviceID == info.DeviceID { + accountID = devices[i].AccountID + break + } + } + } + + if accountID == "" { + accountID = "default" + } + // 2. Fetch Presets from :8090 m.syncPresets(deviceIP, accountID, deviceID) diff --git a/pkg/service/setup/setup_test.go b/pkg/service/setup/setup_test.go index 68ef214..93eafe3 100644 --- a/pkg/service/setup/setup_test.go +++ b/pkg/service/setup/setup_test.go @@ -776,11 +776,12 @@ func TestBackupConfigOffDevice(t *testing.T) { m := NewManager("http://localhost:8000", ds, nil) serial := "08DF1F0BA325" + accountID := "3230304" // Mock info server infoServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/xml") - fmt.Fprintf(w, `TestSCM%s`, serial, serial) + fmt.Fprintf(w, `Test%sSCM%s`, serial, accountID, serial) })) defer infoServer.Close() @@ -808,7 +809,7 @@ func TestBackupConfigOffDevice(t *testing.T) { } // Verify files were created in datastore - deviceDir := m.DataStore.AccountDeviceDir("default", serial) + deviceDir := m.DataStore.AccountDeviceDir(accountID, serial) configPath := filepath.Join(deviceDir, "SoundTouchSdkPrivateCfg.xml.bak") hostsPath := filepath.Join(deviceDir, "hosts.bak")