Refactor data storage to use account-based hierarchy and update Web UI

This commit is contained in:
Tobias Gesellchen
2026-02-15 15:36:01 +01:00
parent 735187cae8
commit 505e6dd760
18 changed files with 310 additions and 138 deletions
+18 -18
View File
@@ -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)
---
+2
View File
@@ -1,4 +1,6 @@
accounts/
certs/
default/
interactions/
patterns.json
settings.json
-17
View File
@@ -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}"
}
]
+7 -18
View File
@@ -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
}
+10 -5
View File
@@ -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
+1 -1
View File
@@ -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)
+5 -5
View File
@@ -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 {
+1 -4
View File
@@ -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)
+1 -1
View File
@@ -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)
}
+52 -17
View File
@@ -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
}
+14 -4
View File
@@ -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)
+4 -4
View File
@@ -60,14 +60,14 @@
<div class="info-box safety-box">
<strong>⚠️ Safety First:</strong> Before starting any migration, please read our
<a href="https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.md" target="_blank">Professional Migration & Safety Guide</a>.
<a href="https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html" target="_blank">Professional Migration & Safety Guide</a>.
The toolkit automatically creates backups, but understanding the process is key to a smooth transition.
</div>
<h3>Useful Links</h3>
<ul>
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.md" target="_blank">Cloud Shutdown Survival Guide</a></li>
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.md" target="_blank">CLI Reference</a></li>
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html" target="_blank">Cloud Shutdown Survival Guide</a></li>
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html" target="_blank">CLI Reference</a></li>
</ul>
</div>
@@ -87,7 +87,7 @@
<div style="margin-bottom: 20px;">
<label for="proxy-domain">Proxy Domain:</label>
<input type="text" id="proxy-domain" placeholder="http://192.168.x.x:8000" style="width: 300px;">
<span style="font-size: 0.8em; color: #666;">(Upstream proxy URL)</span>
<span style="font-size: 0.8em; color: #666;">(Upstream proxy URL - usually the same as Target Domain)</span>
</div>
<div style="margin-bottom: 20px;">
<label for="discovery-interval">Discovery Interval:</label>
+16 -5
View File
@@ -92,7 +92,7 @@ async function fetchDevices() {
if (devices.length === 0) {
container.innerHTML = 'No devices known yet.';
} else {
let html = '<table><tr><th>Name</th><th>IP Address</th><th>Model</th><th>Serial Number</th><th>Firmware</th><th>Method</th><th>Action</th></tr>';
let html = '<table><tr><th>Name & Model</th><th>IP Address</th><th>Device & Account ID</th><th>Firmware & Serial</th><th>Method</th><th>Action</th></tr>';
// Clear and repopulate selectors
const currentSyncVal = syncSelector.value;
@@ -104,11 +104,10 @@ async function fetchDevices() {
const methodLabel = d.discovery_method === 'manual' ? '👤 Manual' : '🔍 Auto';
html += `
<tr id="device-row-${d.ip_address.replace(/\./g, '-')}">
<td class="col-name">${d.name}</td>
<td class="col-name-model"><div class="col-name">${d.name}</div><div class="col-model" style="font-size: 0.8em; color: #666;">${d.product_code}</div></td>
<td class="col-ip">${d.ip_address}</td>
<td class="col-model">${d.product_code}</td>
<td class="col-serial">${d.device_serial_number}</td>
<td class="col-firmware">${d.firmware_version || '0.0.0'}</td>
<td class="col-ids"><div class="col-deviceid">${d.device_id}</div><div class="col-accountid" style="font-size: 0.8em; color: #666;">${d.account_id || 'default'}</div></td>
<td class="col-fw-serial"><div class="col-firmware">${d.firmware_version || '0.0.0'}</div><div class="col-serial" style="font-size: 0.8em; color: #666;">${d.device_serial_number}</div></td>
<td class="col-method">${methodLabel}</td>
<td>
<button onclick="prepareSync('${d.ip_address}')">Sync Data</button>
@@ -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';
+21 -8
View File
@@ -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}
}
+61
View File
@@ -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)
}
+27 -15
View File
@@ -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{
+67 -14
View File
@@ -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)
+3 -2
View File
@@ -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, `<info deviceID="%s"><name>Test</name><components><component><componentCategory>SCM</componentCategory><serialNumber>%s</serialNumber></component></components></info>`, serial, serial)
fmt.Fprintf(w, `<info deviceID="%s"><name>Test</name><margeAccountUUID>%s</margeAccountUUID><components><component><componentCategory>SCM</componentCategory><serialNumber>%s</serialNumber></component></components></info>`, 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")