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 @@
| Name | IP Address | Model | Serial Number | Firmware | Method | Action |
|---|
| Name & Model | IP Address | Device & Account ID | Firmware & Serial | Method | Action | ||||
|---|---|---|---|---|---|---|---|---|---|
| ${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, ` |