diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index d39655e..c173d27 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -270,6 +270,7 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M r.Route("/setup", func(r chi.Router) { r.Get("/devices", server.HandleListDiscoveredDevices) + r.Post("/devices", server.HandleAddManualDevice) r.Post("/discover", server.HandleTriggerDiscovery) r.Get("/discovery-status", server.HandleGetDiscoveryStatus) r.Get("/settings", server.HandleGetSettings) @@ -280,6 +281,7 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M r.Post("/ensure-remote-services/{deviceIP}", server.HandleEnsureRemoteServices) r.Post("/remove-remote-services/{deviceIP}", server.HandleRemoveRemoteServices) r.Post("/backup/{deviceIP}", server.HandleBackupConfig) + r.Post("/sync/{deviceIP}", server.HandleInitialSync) r.Post("/test-connection/{deviceIP}", server.HandleTestConnection) r.Post("/test-hosts/{deviceIP}", server.HandleTestHostsRedirection) r.Get("/ca.crt", server.HandleGetCACert) diff --git a/docs/CLOUD-SHUTDOWN-GUIDE.md b/docs/CLOUD-SHUTDOWN-GUIDE.md new file mode 100644 index 0000000..8e32d4c --- /dev/null +++ b/docs/CLOUD-SHUTDOWN-GUIDE.md @@ -0,0 +1,81 @@ +### Bose Cloud Shutdown: Survival Guide for SoundTouch + +With Bose's announcement of discontinuing cloud support for SoundTouch devices in May 2026, this project provides the necessary tools to keep your speakers fully functional using a local emulation service. + +This guide explains how to set up the `soundtouch-service` to run your devices independently of Bose's servers. + +--- + +### Supported Use Cases + +1. **Local Service Emulation**: The service emulates Bose's BMX (Bose Media eXchange) and Marge services, which handle content registries, presets, recents, and software update checks. +2. **Traffic Redirection**: Tools are provided to redirect your speakers to this local service instead of `*.bose.com`. +3. **Offline Operation**: Once redirected, the speakers function without needing to reach Bose's servers. +4. **Preset & Recent Management**: Captures and stores presets and "recently played" items locally. + +--- + +### Setup Steps + +To set up your SoundTouch system for local-only operation, follow these steps: + +#### 1. Install and Start the Service +Run the `soundtouch-service` on a machine that is always on (like a Raspberry Pi or a NAS) within your local network. + +```bash +# Install the service +go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest + +# Start the service (defaults to http://localhost:8000) +soundtouch-service +``` + +#### 2. Access the Management UI +Open your web browser and navigate to the service's web interface: +`http://:8000/`, e.g. `http://localhost:8000/` + +*Note: The service also supports a `/web/` path for management.* + +#### 3. Enable SSH on Your Speakers +To migrate your speakers, the service needs SSH access. You can enable it by: +1. Creating an empty file named `remote_services` on a USB stick. +2. Inserting the USB stick into the SoundTouch speaker's service port. +3. Rebooting the speaker. +Once enabled, you can log in as `root` (no password). + +#### 4. Discover and Sync Device Data +The web interface handles the entire process in a guided flow across four tabs: + +* **Step 1: Devices**: The service automatically scans for SoundTouch devices on your network. If a device is not found, you can manually add its IP address. +* **Step 2: Data Sync**: Select your device and click "Start Sync". This will automatically fetch your presets, recents, and configured sources from the speaker and store them in the local `data/` directory. +* **Step 3: Migration**: Choose your redirection method (XML Recommended) and click "Confirm Migration & Reboot". +* **Step 4: Settings**: Configure global server URLs and proxy behavior (logging, redaction). + +#### 5. Verify Your Local Data +Once migrated, your speaker will use the data captured during the Sync step. +* The service stores data in the `data/` directory, organized by device serial number (e.g., `data/default/devices//`). +* **Automatic Capture**: As you use the device (changing presets, playing new music), the service continues to "learn" and update your local files. + +--- + +### Comparison with other implementations (soundcork) +Our implementation (`soundtouch-service`) is largely compatible with the Python-based `soundcork` project but offers several advantages: +- **Web UI**: Integrated management interface for discovery and migration. +- **Surgical Migration**: Uses XML-based redirection by default, which is less invasive than `/etc/hosts`. +- **Automated SSL**: Handles Root CA injection automatically for secure communication. +- **Proxy Support**: Can proxy requests to original Bose servers while "learning" your configuration. + +--- + +### Alternative: DNS Redirection (No SSH) +If you prefer not to modify your speakers via SSH, you can use a local DNS server (like Pi-hole, AdGuard Home, or Unbound) to point the following domains to your local server's IP: + +* `bmx.bose.com` +* `streaming.bose.com` +* `updates.bose.com` +* `stats.bose.com` +* `content.api.bose.io` + +*Note: DNS redirection for HTTPS services requires the speakers to trust your local service's SSL certificate. The SSH-based migration handles this automatically by injecting the CA.* + +--- diff --git a/pkg/models/models.go b/pkg/models/models.go index c7a7a89..7d0bb31 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -160,12 +160,19 @@ type ServiceRecent struct { // ConfiguredSource represents a configured media source with authentication details. type ConfiguredSource struct { - DisplayName string `json:"display_name" xml:"sourcename"` - ID string `json:"id" xml:"id,attr"` - Secret string `json:"secret" xml:"credential"` - SecretType string `json:"secret_type" xml:"credential_type,attr"` - SourceKeyType string `json:"source_key_type" xml:"sourceproviderid"` - SourceKeyAccount string `json:"source_key_account" xml:"username"` + DisplayName string `json:"display_name" xml:"displayName,attr"` + ID string `json:"id" xml:"id,attr"` + Secret string `json:"secret" xml:"secret,attr"` + SecretType string `json:"secret_type" xml:"secretType,attr"` + SourceKey struct { + Type string `xml:"type,attr"` + Account string `xml:"account,attr"` + } `json:"source_key" xml:"sourceKey"` + + // Legacy fields for backward compatibility in code if needed, + // though it's better to update the code to use SourceKey. + SourceKeyType string `json:"source_key_type" xml:"-"` + SourceKeyAccount string `json:"source_key_account" xml:"-"` } // ServiceDeviceInfo represents information about a SoundTouch device. @@ -177,6 +184,7 @@ type ServiceDeviceInfo struct { FirmwareVersion string `json:"firmware_version" xml:"softwareVersion"` IPAddress string `json:"ip_address" xml:"ipAddress"` Name string `json:"name" xml:"name"` + DiscoveryMethod string `json:"discovery_method,omitempty"` } // CustomerSupportDevice represents device information for customer support purposes. diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index bdb077c..3815e16 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -132,7 +132,9 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) { } accDevices := ds.listDevicesInAccount(dir, acc.Name()) - for _, info := range accDevices { + for i := range accDevices { + info := accDevices[i] + key := info.DeviceID if key == "" { key = info.IPAddress @@ -219,6 +221,7 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo Type string `xml:"type,attr"` IPAddress string `xml:"ipAddress"` } `xml:"networkInfo"` + DiscoveryMethod string `xml:"discoveryMethod"` } if err := xml.Unmarshal(data, &info); err != nil { @@ -226,9 +229,10 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo } deviceInfo := &models.ServiceDeviceInfo{ - DeviceID: info.DeviceID, - ProductCode: fmt.Sprintf("%s %s", info.Type, info.ModuleType), - Name: info.Name, + DeviceID: info.DeviceID, + ProductCode: fmt.Sprintf("%s %s", info.Type, info.ModuleType), + Name: info.Name, + DiscoveryMethod: info.DiscoveryMethod, } for _, comp := range info.Components { @@ -250,9 +254,9 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo return deviceInfo, nil } -// GetPresets retrieves all presets for the specified account. -func (ds *DataStore) GetPresets(account string) ([]models.ServicePreset, error) { - path := filepath.Join(ds.AccountDir(account), constants.PresetsFile) +// GetPresets retrieves all presets for the specified account and device. +func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset, error) { + path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile) data, err := os.ReadFile(path) if err != nil { @@ -303,9 +307,9 @@ func (ds *DataStore) GetPresets(account string) ([]models.ServicePreset, error) return presets, nil } -// SavePresets saves the preset list for the specified account. -func (ds *DataStore) SavePresets(account string, presets []models.ServicePreset) error { - path := filepath.Join(ds.AccountDir(account), constants.PresetsFile) +// SavePresets saves the preset list for the specified account and device. +func (ds *DataStore) SavePresets(account, device string, presets []models.ServicePreset) error { + path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile) type PresetXML struct { ID string `xml:"id,attr"` @@ -357,9 +361,9 @@ func (ds *DataStore) SavePresets(account string, presets []models.ServicePreset) return os.WriteFile(path, append(header, data...), 0644) } -// GetRecents retrieves all recent items for the specified account. -func (ds *DataStore) GetRecents(account string) ([]models.ServiceRecent, error) { - path := filepath.Join(ds.AccountDir(account), constants.RecentsFile) +// GetRecents retrieves all recent items for the specified account and device. +func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent, error) { + path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile) data, err := os.ReadFile(path) if err != nil { @@ -410,9 +414,9 @@ func (ds *DataStore) GetRecents(account string) ([]models.ServiceRecent, error) return recents, nil } -// SaveRecents saves the recent items list for the specified account. -func (ds *DataStore) SaveRecents(account string, recents []models.ServiceRecent) error { - path := filepath.Join(ds.AccountDir(account), constants.RecentsFile) +// SaveRecents saves the recent items list for the specified account and device. +func (ds *DataStore) SaveRecents(account, device string, recents []models.ServiceRecent) error { + path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile) type RecentXML struct { ID string `xml:"id,attr"` @@ -494,13 +498,14 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service } type InfoXML struct { - XMLName xml.Name `xml:"info"` - DeviceID string `xml:"deviceID,attr"` - Name string `xml:"name"` - Type string `xml:"type"` - ModuleType string `xml:"moduleType"` - Components []ComponentXML `xml:"components>component"` - NetworkInfo []NetworkInfoXML `xml:"networkInfo"` + XMLName xml.Name `xml:"info"` + DeviceID string `xml:"deviceID,attr"` + Name string `xml:"name"` + Type string `xml:"type"` + ModuleType string `xml:"moduleType"` + Components []ComponentXML `xml:"components>component"` + NetworkInfo []NetworkInfoXML `xml:"networkInfo"` + DiscoveryMethod string `xml:"discoveryMethod,omitempty"` } // Parsing product code back to type and moduleType (best effort) @@ -539,6 +544,7 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service IPAddress: info.IPAddress, }, }, + DiscoveryMethod: info.DiscoveryMethod, } data, err := xml.MarshalIndent(ix, "", " ") @@ -557,9 +563,9 @@ func (ds *DataStore) RemoveDevice(account, device string) error { return os.RemoveAll(dir) } -// GetConfiguredSources retrieves all configured sources for the specified account. -func (ds *DataStore) GetConfiguredSources(account string) ([]models.ConfiguredSource, error) { - path := filepath.Join(ds.AccountDir(account), constants.SourcesFile) +// GetConfiguredSources retrieves all configured sources for the specified account and device. +func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.ConfiguredSource, error) { + path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile) data, err := os.ReadFile(path) if err != nil { @@ -567,81 +573,52 @@ func (ds *DataStore) GetConfiguredSources(account string) ([]models.ConfiguredSo } var sourcesWrap struct { - Sources []struct { - DisplayName string `xml:"displayName,attr"` - ID string `xml:"id,attr"` - Secret string `xml:"secret,attr"` - SecretType string `xml:"secretType,attr"` - SourceKey struct { - Account string `xml:"account,attr"` - Type string `xml:"type,attr"` - } `xml:"sourceKey"` - } `xml:"source"` + Sources []models.ConfiguredSource `xml:"source"` } if err := xml.Unmarshal(data, &sourcesWrap); err != nil { return nil, fmt.Errorf("malformed sources XML at %s: %w", path, err) } - var sources []models.ConfiguredSource - - lastID := 100001 - - for _, s := range sourcesWrap.Sources { - id := s.ID - if id == "" { - id = strconv.Itoa(lastID) - lastID++ + for i := range sourcesWrap.Sources { + s := &sourcesWrap.Sources[i] + if s.ID == "" { + s.ID = strconv.Itoa(100001 + i) } - - sources = append(sources, models.ConfiguredSource{ - DisplayName: s.DisplayName, - ID: id, - Secret: s.Secret, - SecretType: s.SecretType, - SourceKeyType: s.SourceKey.Type, - SourceKeyAccount: s.SourceKey.Account, - }) + // Sync legacy fields + s.SourceKeyType = s.SourceKey.Type + s.SourceKeyAccount = s.SourceKey.Account } - return sources, nil + return sourcesWrap.Sources, nil } -// SaveConfiguredSources saves the configured sources list for the specified account. -func (ds *DataStore) SaveConfiguredSources(account string, sources []models.ConfiguredSource) error { - path := filepath.Join(ds.AccountDir(account), constants.SourcesFile) +// SaveConfiguredSources saves the configured sources list for the specified account and device. +func (ds *DataStore) SaveConfiguredSources(account, device string, sources []models.ConfiguredSource) error { + path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile) if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } - type sourceXML struct { - DisplayName string `xml:"displayName,attr"` - ID string `xml:"id,attr"` - Secret string `xml:"secret,attr"` - SecretType string `xml:"secretType,attr"` - SourceKey struct { - Account string `xml:"account,attr"` - Type string `xml:"type,attr"` - } `xml:"sourceKey"` - } - type sourcesWrap struct { - XMLName xml.Name `xml:"sources"` - Sources []sourceXML `xml:"source"` + XMLName xml.Name `xml:"sources"` + Sources []models.ConfiguredSource `xml:"source"` } - wrap := sourcesWrap{} - - for _, s := range sources { - sx := sourceXML{ - DisplayName: s.DisplayName, - ID: s.ID, - Secret: s.Secret, - SecretType: s.SecretType, + // Ensure SourceKey is populated from legacy fields if necessary before saving + for i := range sources { + s := &sources[i] + if s.SourceKey.Type == "" && s.SourceKeyType != "" { + s.SourceKey.Type = s.SourceKeyType } - sx.SourceKey.Account = s.SourceKeyAccount - sx.SourceKey.Type = s.SourceKeyType - wrap.Sources = append(wrap.Sources, sx) + + if s.SourceKey.Account == "" && s.SourceKeyAccount != "" { + s.SourceKey.Account = s.SourceKeyAccount + } + } + + wrap := sourcesWrap{ + Sources: sources, } data, err := xml.MarshalIndent(wrap, "", " ") @@ -675,9 +652,9 @@ func (ds *DataStore) Initialize() error { return nil } -// GetETagForPresets returns the ETag (modification time) for the presets file. -func (ds *DataStore) GetETagForPresets(account string) int64 { - path := filepath.Join(ds.AccountDir(account), constants.PresetsFile) +// GetETagForPresets returns the ETag (modification time) for the presets file for a specific device. +func (ds *DataStore) GetETagForPresets(account, device string) int64 { + path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile) info, err := os.Stat(path) if err != nil { @@ -687,9 +664,9 @@ func (ds *DataStore) GetETagForPresets(account string) int64 { return info.ModTime().UnixNano() / int64(time.Millisecond) } -// GetETagForSources returns the ETag (modification time) for the sources file. -func (ds *DataStore) GetETagForSources(account string) int64 { - path := filepath.Join(ds.AccountDir(account), constants.SourcesFile) +// GetETagForSources returns the ETag (modification time) for the sources file for a specific device. +func (ds *DataStore) GetETagForSources(account, device string) int64 { + path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile) info, err := os.Stat(path) if err != nil { @@ -699,9 +676,9 @@ func (ds *DataStore) GetETagForSources(account string) int64 { return info.ModTime().UnixNano() / int64(time.Millisecond) } -// GetETagForRecents returns the ETag (modification time) for the recents file. -func (ds *DataStore) GetETagForRecents(account string) int64 { - path := filepath.Join(ds.AccountDir(account), constants.RecentsFile) +// GetETagForRecents returns the ETag (modification time) for the recents file for a specific device. +func (ds *DataStore) GetETagForRecents(account, device string) int64 { + path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile) info, err := os.Stat(path) if err != nil { @@ -711,11 +688,11 @@ func (ds *DataStore) GetETagForRecents(account string) int64 { return info.ModTime().UnixNano() / int64(time.Millisecond) } -// GetETagForAccount returns the highest ETag among presets, sources, and recents for the account. -func (ds *DataStore) GetETagForAccount(account string) int64 { - e1 := ds.GetETagForPresets(account) - e2 := ds.GetETagForSources(account) - e3 := ds.GetETagForRecents(account) +// GetETagForAccount returns the highest ETag among presets, sources, and recents for the account and device. +func (ds *DataStore) GetETagForAccount(account, device string) int64 { + e1 := ds.GetETagForPresets(account, device) + e2 := ds.GetETagForSources(account, device) + e3 := ds.GetETagForRecents(account, device) maxETag := e1 if e2 > maxETag { diff --git a/pkg/service/datastore/datastore_test.go b/pkg/service/datastore/datastore_test.go index 588c567..76a2500 100644 --- a/pkg/service/datastore/datastore_test.go +++ b/pkg/service/datastore/datastore_test.go @@ -49,12 +49,12 @@ func TestDataStore(t *testing.T) { }, } - err = ds.SavePresets(account, presets) + err = ds.SavePresets(account, device, presets) if err != nil { t.Errorf("SavePresets failed: %v", err) } - loadedPresets, err := ds.GetPresets(account) + loadedPresets, err := ds.GetPresets(account, device) if err != nil { t.Errorf("GetPresets failed: %v", err) } @@ -72,12 +72,12 @@ func TestDataStore(t *testing.T) { }, } - err = ds.SaveRecents(account, recents) + err = ds.SaveRecents(account, device, recents) if err != nil { t.Errorf("SaveRecents failed: %v", err) } - loadedRecents, err := ds.GetRecents(account) + loadedRecents, err := ds.GetRecents(account, device) if err != nil { t.Errorf("GetRecents failed: %v", err) } @@ -294,29 +294,37 @@ func TestConfiguredSources(t *testing.T) { sources := []models.ConfiguredSource{ { - DisplayName: "Source 1", - ID: "101", - Secret: "secret1", - SecretType: "type1", + DisplayName: "Source 1", + ID: "101", + Secret: "secret1", + SecretType: "type1", + SourceKey: struct { + Type string `xml:"type,attr"` + Account string `xml:"account,attr"` + }{Type: "TUNEIN", Account: "user1"}, SourceKeyType: "TUNEIN", SourceKeyAccount: "user1", }, { - DisplayName: "Source 2", - ID: "102", - Secret: "secret2", - SecretType: "type2", + DisplayName: "Source 2", + ID: "102", + Secret: "secret2", + SecretType: "type2", + SourceKey: struct { + Type string `xml:"type,attr"` + Account string `xml:"account,attr"` + }{Type: "PANDORA", Account: "user2"}, SourceKeyType: "PANDORA", SourceKeyAccount: "user2", }, } - err := ds.SaveConfiguredSources(account, sources) + err := ds.SaveConfiguredSources(account, "any", sources) if err != nil { t.Fatalf("SaveConfiguredSources failed: %v", err) } - loadedSources, err := ds.GetConfiguredSources(account) + loadedSources, err := ds.GetConfiguredSources(account, "any") if err != nil { t.Fatalf("GetConfiguredSources failed: %v", err) } @@ -343,12 +351,12 @@ func TestConfiguredSources(t *testing.T) { }, } - err = ds.SaveConfiguredSources(account, sources2) + err = ds.SaveConfiguredSources(account, "any", sources2) if err != nil { t.Fatal(err) } - loadedSources2, err := ds.GetConfiguredSources(account) + loadedSources2, err := ds.GetConfiguredSources(account, "any") if err != nil { t.Fatal(err) } diff --git a/pkg/service/handlers/handlers_etag_test.go b/pkg/service/handlers/handlers_etag_test.go index 2311db9..b50e7e9 100644 --- a/pkg/service/handlers/handlers_etag_test.go +++ b/pkg/service/handlers/handlers_etag_test.go @@ -23,17 +23,19 @@ func TestMargeETags(t *testing.T) { ds := datastore.NewDataStore(tempDir) account := "12345" + deviceID := "DEV1" accountDir := filepath.Join(tempDir, account) - _ = os.MkdirAll(accountDir, 0755) + deviceDir := filepath.Join(accountDir, "devices", deviceID) + _ = os.MkdirAll(deviceDir, 0755) // Create some initial data - presetsFile := filepath.Join(accountDir, "Presets.xml") + presetsFile := filepath.Join(deviceDir, "Presets.xml") _ = os.WriteFile(presetsFile, []byte(""), 0644) - sourcesFile := filepath.Join(accountDir, "Sources.xml") + sourcesFile := filepath.Join(deviceDir, "Sources.xml") _ = os.WriteFile(sourcesFile, []byte(""), 0644) - recentsFile := filepath.Join(accountDir, "Recents.xml") + recentsFile := filepath.Join(deviceDir, "Recents.xml") _ = os.WriteFile(recentsFile, []byte(""), 0644) // Ensure devices directory exists for AccountFull diff --git a/pkg/service/handlers/handlers_marge.go b/pkg/service/handlers/handlers_marge.go index f21b5e1..4ce67fc 100644 --- a/pkg/service/handlers/handlers_marge.go +++ b/pkg/service/handlers/handlers_marge.go @@ -36,7 +36,9 @@ func (s *Server) HandleMargeSourceProviders(w http.ResponseWriter, r *http.Reque func (s *Server) HandleMargeAccountFull(w http.ResponseWriter, r *http.Request) { account := chi.URLParam(r, "account") - etag := strconv.FormatInt(s.ds.GetETagForAccount(account), 10) + device := r.URL.Query().Get("device") + + etag := strconv.FormatInt(s.ds.GetETagForAccount(account, device), 10) if r.Header.Get("If-None-Match") == etag { w.WriteHeader(http.StatusNotModified) return @@ -79,14 +81,15 @@ func (s *Server) HandleMargeSoftwareUpdate(w http.ResponseWriter, r *http.Reques // HandleMargePresets returns the Marge presets for a device. func (s *Server) HandleMargePresets(w http.ResponseWriter, r *http.Request) { account := chi.URLParam(r, "account") + device := chi.URLParam(r, "device") - etag := strconv.FormatInt(s.ds.GetETagForPresets(account), 10) + etag := strconv.FormatInt(s.ds.GetETagForPresets(account, device), 10) if r.Header.Get("If-None-Match") == etag { w.WriteHeader(http.StatusNotModified) return } - data, err := marge.PresetsToXML(s.ds, account) + data, err := marge.PresetsToXML(s.ds, account, device) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -102,7 +105,7 @@ func (s *Server) HandleMargeUpdatePreset(w http.ResponseWriter, r *http.Request) account := chi.URLParam(r, "account") device := chi.URLParam(r, "device") - etag := strconv.FormatInt(s.ds.GetETagForPresets(account), 10) + etag := strconv.FormatInt(s.ds.GetETagForPresets(account, device), 10) w.Header()["ETag"] = []string{etag} presetNumberStr := chi.URLParam(r, "presetNumber") @@ -134,7 +137,7 @@ func (s *Server) HandleMargeAddRecent(w http.ResponseWriter, r *http.Request) { account := chi.URLParam(r, "account") device := chi.URLParam(r, "device") - etag := strconv.FormatInt(s.ds.GetETagForRecents(account), 10) + etag := strconv.FormatInt(s.ds.GetETagForRecents(account, device), 10) w.Header()["ETag"] = []string{etag} body, err := io.ReadAll(r.Body) diff --git a/pkg/service/handlers/handlers_marge_test.go b/pkg/service/handlers/handlers_marge_test.go index d64787b..76aefd1 100644 --- a/pkg/service/handlers/handlers_marge_test.go +++ b/pkg/service/handlers/handlers_marge_test.go @@ -135,12 +135,14 @@ func TestMargePresets(t *testing.T) { ds := datastore.NewDataStore(tempDir) account := "12345" + deviceID := "any" accountDir := filepath.Join(tempDir, account) - err = os.MkdirAll(accountDir, 0755) + deviceDir := filepath.Join(accountDir, "devices", deviceID) + err = os.MkdirAll(deviceDir, 0755) if err != nil { - t.Fatalf("Failed to create account dir: %v", err) + t.Fatalf("Failed to create device dir: %v", err) } r, _ := setupRouter("http://localhost:8001", ds) @@ -149,24 +151,17 @@ func TestMargePresets(t *testing.T) { defer ts.Close() // Mock Sources.xml and Presets.xml - if err := os.WriteFile(filepath.Join(accountDir, "Sources.xml"), []byte(` + if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(` - - 2012-09-19T12:43:00.000+00:00 - - TUNEIN - 1 - TUNEIN - - 2012-09-19T12:43:00.000+00:00 - + + `), 0644); err != nil { t.Fatalf("Failed to write Sources.xml: %v", err) } - if err := os.WriteFile(filepath.Join(accountDir, "Presets.xml"), []byte(` + if err := os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte(` @@ -211,26 +206,28 @@ func TestMargeUpdatePreset(t *testing.T) { ds := datastore.NewDataStore(tempDir) account := "12345" + deviceID := "DEV1" accountDir := filepath.Join(tempDir, account) - err = os.MkdirAll(accountDir, 0755) + deviceDir := filepath.Join(accountDir, "devices", deviceID) + err = os.MkdirAll(deviceDir, 0755) if err != nil { - t.Fatalf("Failed to create account dir: %v", err) + t.Fatalf("Failed to create device dir: %v", err) } // Mock Sources.xml - if err := os.WriteFile(filepath.Join(accountDir, "Sources.xml"), []byte(` + if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(` - - TUNEIN + + `), 0644); err != nil { t.Fatalf("Failed to write Sources.xml: %v", err) } - if err := os.WriteFile(filepath.Join(accountDir, "Presets.xml"), []byte(``), 0644); err != nil { + if err := os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte(``), 0644); err != nil { t.Fatalf("Failed to write Presets.xml: %v", err) } @@ -248,7 +245,7 @@ func TestMargeUpdatePreset(t *testing.T) { http://example.com/new.jpg ` - res, err := http.Post(ts.URL+"/marge/accounts/"+account+"/devices/DEV1/presets/1", "application/xml", strings.NewReader(payload)) + res, err := http.Post(ts.URL+"/marge/accounts/"+account+"/devices/"+deviceID+"/presets/1", "application/xml", strings.NewReader(payload)) if err != nil { t.Fatal(err) } @@ -261,7 +258,7 @@ func TestMargeUpdatePreset(t *testing.T) { } // Verify file was saved - presetData, _ := os.ReadFile(filepath.Join(accountDir, "Presets.xml")) + presetData, _ := os.ReadFile(filepath.Join(deviceDir, "Presets.xml")) if !strings.Contains(string(presetData), "New Preset") { t.Error("Preset was not saved to datastore") } @@ -278,26 +275,28 @@ func TestMargeDeviceInfo(t *testing.T) { ds := datastore.NewDataStore(tempDir) account := "12345" + deviceID := "DEV1" accountDir := filepath.Join(tempDir, account) - err = os.MkdirAll(accountDir, 0755) + deviceDir := filepath.Join(accountDir, "devices", deviceID) + err = os.MkdirAll(deviceDir, 0755) if err != nil { - t.Fatalf("Failed to create account dir: %v", err) + t.Fatalf("Failed to create device dir: %v", err) } // Mock Sources.xml - if err := os.WriteFile(filepath.Join(accountDir, "Sources.xml"), []byte(` + if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(` - - TUNEIN + + `), 0644); err != nil { t.Fatalf("Failed to write Sources.xml: %v", err) } - if err := os.WriteFile(filepath.Join(accountDir, "Recents.xml"), []byte(``), 0644); err != nil { + if err := os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(``), 0644); err != nil { t.Fatalf("Failed to write Recents.xml: %v", err) } @@ -314,7 +313,7 @@ func TestMargeDeviceInfo(t *testing.T) { station ` - res, err := http.Post(ts.URL+"/marge/accounts/"+account+"/devices/DEV1/recents", "application/xml", strings.NewReader(payload)) + res, err := http.Post(ts.URL+"/marge/accounts/"+account+"/devices/"+deviceID+"/recents", "application/xml", strings.NewReader(payload)) if err != nil { t.Fatal(err) } @@ -326,7 +325,7 @@ func TestMargeDeviceInfo(t *testing.T) { } // Verify file was saved - recentData, _ := os.ReadFile(filepath.Join(accountDir, "Recents.xml")) + recentData, _ := os.ReadFile(filepath.Join(deviceDir, "Recents.xml")) if !strings.Contains(string(recentData), "Recent Station") { t.Error("Recent was not saved to datastore") } diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go index 1bf3dbf..e62c1a0 100644 --- a/pkg/service/handlers/handlers_setup.go +++ b/pkg/service/handlers/handlers_setup.go @@ -1,10 +1,12 @@ package handlers import ( + "context" "encoding/json" "net/http" "os" + "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/gesellix/bose-soundtouch/pkg/service/setup" "github.com/go-chi/chi/v5" ) @@ -25,9 +27,53 @@ func (s *Server) HandleListDiscoveredDevices(w http.ResponseWriter, _ *http.Requ } } +// HandleAddManualDevice adds a device manually by IP. +func (s *Server) HandleAddManualDevice(w http.ResponseWriter, r *http.Request) { + var body struct { + IP string `json:"ip"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if body.IP == "" { + http.Error(w, "IP address is required", http.StatusBadRequest) + return + } + + // Try to get live info + liveInfo, err := s.sm.GetLiveDeviceInfo(body.IP) + if err != nil { + // Even if we can't get live info, we might want to add it? + // But usually we need at least the serial for proper account management. + http.Error(w, "Failed to reach device at "+body.IP+": "+err.Error(), http.StatusBadGateway) + return + } + + // Reuse handleDiscoveredDevice logic via a fake models.DiscoveredDevice + d := models.DiscoveredDevice{ + Name: liveInfo.Name, + Host: body.IP, + ModelID: liveInfo.Type, + SerialNo: liveInfo.SerialNumber, + DiscoveryMethod: "manual", + } + + s.handleDiscoveredDevice(d) + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(map[string]bool{"ok": true}); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} + // HandleTriggerDiscovery triggers a new device discovery scan. -func (s *Server) HandleTriggerDiscovery(w http.ResponseWriter, r *http.Request) { - go s.DiscoverDevices(r.Context()) +func (s *Server) HandleTriggerDiscovery(w http.ResponseWriter, _ *http.Request) { + //nolint:contextcheck + go s.DiscoverDevices(context.Background()) w.WriteHeader(http.StatusAccepted) _, _ = w.Write([]byte(`{"status": "Discovery started"}`)) @@ -388,6 +434,23 @@ func (s *Server) HandleTestHostsRedirection(w http.ResponseWriter, r *http.Reque } } +// HandleInitialSync fetches presets, recents and sources from the device and saves them to the datastore. +func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) { + deviceIP := chi.URLParam(r, "deviceIP") + if deviceIP == "" { + http.Error(w, "Missing deviceIP", http.StatusBadRequest) + return + } + + if err := s.sm.SyncDeviceData(deviceIP); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok": true}`)) +} + // HandleTestConnection performs a connection check from the device to the server. func (s *Server) HandleTestConnection(w http.ResponseWriter, r *http.Request) { deviceIP := chi.URLParam(r, "deviceIP") diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index 410833f..9f5f9f5 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -44,16 +44,18 @@ func (s *Server) DiscoverDevices(ctx context.Context) { log.Println("Scanning for Bose devices...") + // Use background context if none provided or if it's likely a request context if ctx == nil { - var cancel context.CancelFunc - - ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() + ctx = context.Background() } + // Always wrap in a timeout to prevent hanging forever + discoveryCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + svc := discovery.NewService(10 * time.Second) - devices, err := svc.DiscoverDevices(ctx) + devices, err := svc.DiscoverDevices(discoveryCtx) if err != nil { log.Printf("Discovery error: %v", err) return @@ -94,6 +96,7 @@ func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) { DeviceSerialNumber: d.SerialNo, ProductCode: d.ModelID, FirmwareVersion: "0.0.0", // Unknown from discovery + DiscoveryMethod: d.DiscoveryMethod, } // If we had an IP-based entry and now have a Serial, clean up the IP-based entry @@ -109,7 +112,8 @@ func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) { func (s *Server) findExistingDeviceID(d models.DiscoveredDevice) string { allDevices, _ := s.ds.ListAllDevices() - for _, known := range allDevices { + for i := range allDevices { + known := allDevices[i] if d.SerialNo != "" && (known.DeviceID == d.SerialNo || known.DeviceSerialNumber == d.SerialNo) { if known.DeviceID != "" { return known.DeviceID diff --git a/pkg/service/handlers/web/css/style.css b/pkg/service/handlers/web/css/style.css index 44c4907..9dd453b 100644 --- a/pkg/service/handlers/web/css/style.css +++ b/pkg/service/handlers/web/css/style.css @@ -9,3 +9,28 @@ pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px; .diff-container { display: flex; gap: 10px; } .diff-pane { flex: 1; min-width: 0; } .config-header { font-weight: bold; margin-bottom: 5px; display: block; } + +/* Tabs */ +.tabs { margin-top: 20px; } +.tab-buttons { display: flex; border-bottom: 1px solid #ddd; margin-bottom: 20px; } +.tab-btn { background: #f8f8f8; border: 1px solid #ddd; border-bottom: none; padding: 10px 20px; margin-right: 5px; border-top-left-radius: 4px; border-top-right-radius: 4px; } +.tab-btn:hover { background: #eee; } +.tab-btn.active { background: white; border-bottom: 2px solid #2196F3; font-weight: bold; } +.tab-content { display: none; padding: 10px; } +.tab-content.active { display: block; } + +.device-selection { + margin-bottom: 20px; + padding: 10px; + background-color: #f0f7ff; + border-radius: 4px; + border: 1px solid #d0e0f0; +} +.device-selection label { + font-weight: bold; + margin-right: 10px; +} +.device-selection select { + padding: 5px; + min-width: 250px; +} diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html index 9a15f4e..02a7aa0 100644 --- a/pkg/service/handlers/web/index.html +++ b/pkg/service/handlers/web/index.html @@ -8,149 +8,193 @@

Soundcork Management

-

Discovered Devices

-
Loading devices...
-
-

Manual Entry

- - - -

Settings

-
- - - (This URL will be used for standard services) +
+
+ + + +
-
- - - (This URL will be used to proxy upstream Bose services) -
-
- Proxy Logging: - - -
-
-
- -
-

Migration Summary for

-

SSH Connection:

- - -

Remote Services Enabled:

-

Local Root CA Trusted:

- -
- HTTPS Connection Test:
- Verify the device can reach the server over HTTPS. -
- URL: + +
+

Known Devices

+
Loading devices...
+
+ + +
-
- - -
-
-