mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
Refactor data storage to use account-based hierarchy and update Web UI
This commit is contained in:
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
accounts/
|
||||
certs/
|
||||
default/
|
||||
interactions/
|
||||
patterns.json
|
||||
settings.json
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user