chore: apply final linter fixes and code quality improvements across the service layer

This commit is contained in:
Tobias Gesellchen
2026-02-07 22:36:50 +01:00
parent e65b1ac110
commit 44e48f7307
6 changed files with 179 additions and 131 deletions
+2
View File
@@ -1,3 +1,5 @@
// Package main provides the SoundTouch service daemon that acts as a proxy and management
// interface for Bose SoundTouch devices, providing Marge service emulation and device discovery.
package main
import (
+2
View File
@@ -1,3 +1,5 @@
// Package main provides a demo client for the SoundTouch service API,
// demonstrating how to interact with devices and retrieve media information.
package main
import (
+26
View File
@@ -1,14 +1,19 @@
// Package models defines data structures used for Bose SoundTouch API communication
// and service management. It includes types for BMX (Bose Media eXchange) services,
// device information, presets, recents, and other core data models.
package models
import (
"encoding/xml"
)
// Link represents a navigational link with URL and client usage preferences.
type Link struct {
Href string `json:"href" xml:"href,attr"`
UseInternalClient string `json:"useInternalClient,omitempty" xml:"useInternalClient,attr,omitempty"`
}
// Links contains various navigation links used by BMX services.
type Links struct {
BmxLogout *Link `json:"bmx_logout,omitempty" xml:"bmx_logout,omitempty"`
BmxNavigate *Link `json:"bmx_navigate,omitempty" xml:"bmx_navigate,omitempty"`
@@ -22,6 +27,7 @@ type Links struct {
BmxTrack *Link `json:"bmx_track,omitempty" xml:"bmx_track,omitempty"`
}
// IconSet represents a collection of icons with different sizes for media content.
type IconSet struct {
DefaultAlbumArt string `json:"defaultAlbumArt,omitempty" xml:"defaultAlbumArt,omitempty"`
LargeSvg string `json:"largeSvg" xml:"largeSvg"`
@@ -30,6 +36,7 @@ type IconSet struct {
SmallSvg string `json:"smallSvg" xml:"smallSvg"`
}
// Asset represents a media asset with URL and content type information.
type Asset struct {
Color string `json:"color" xml:"color"`
Description string `json:"description" xml:"description"`
@@ -38,11 +45,13 @@ type Asset struct {
ShortDescription string `json:"shortDescription,omitempty" xml:"shortDescription,omitempty"`
}
// Id represents an identifier structure used in various API responses.
type Id struct {
Name string `json:"name" xml:"name"`
Value int `json:"value" xml:"value"`
}
// BmxService represents a Bose Media eXchange service configuration.
type BmxService struct {
Links *Links `json:"_links,omitempty" xml:"links,omitempty"`
AskAdapter bool `json:"askAdapter" xml:"askAdapter"`
@@ -54,12 +63,14 @@ type BmxService struct {
ID Id `json:"id" xml:"id"`
}
// BmxResponse represents a response from BMX services.
type BmxResponse struct {
Links *Links `json:"_links,omitempty" xml:"links,omitempty"`
AskAgainAfter int `json:"askAgainAfter" xml:"askAgainAfter"`
BmxServices []Service `json:"bmx_services" xml:"bmx_services>service"`
}
// Stream represents audio stream information including URL and format details.
type Stream struct {
Links *Links `json:"_links,omitempty" xml:"links,omitempty"`
BufferingTimeout int `json:"bufferingTimeout,omitempty" xml:"bufferingTimeout,omitempty"`
@@ -69,6 +80,7 @@ type Stream struct {
StreamUrl string `json:"streamUrl" xml:"streamUrl"`
}
// Audio represents audio content metadata including format and quality information.
type Audio struct {
HasPlaylist bool `json:"hasPlaylist" xml:"hasPlaylist"`
IsRealtime bool `json:"isRealtime" xml:"isRealtime"`
@@ -77,6 +89,7 @@ type Audio struct {
Streams []Stream `json:"streams" xml:"streams>stream"`
}
// BmxPlaybackResponse represents a playback response from BMX services.
type BmxPlaybackResponse struct {
Links *Links `json:"_links,omitempty" xml:"links,omitempty"`
Artist struct {
@@ -92,12 +105,14 @@ type BmxPlaybackResponse struct {
RepeatDisabled bool `json:"repeat_disabled,omitempty" xml:"repeatDisabled,omitempty"`
}
// Track represents track information for media playback.
type Track struct {
Links *Links `json:"_links,omitempty" xml:"links,omitempty"`
IsSelected bool `json:"isSelected" xml:"isSelected"`
Name string `json:"name" xml:"name"`
}
// BmxPodcastInfoResponse represents podcast information from BMX services.
type BmxPodcastInfoResponse struct {
Links *Links `json:"_links,omitempty" xml:"links,omitempty"`
Name string `json:"name" xml:"name"`
@@ -107,6 +122,7 @@ type BmxPodcastInfoResponse struct {
Tracks []Track `json:"tracks" xml:"tracks>track"`
}
// SourceProvider represents a media source provider configuration.
type SourceProvider struct {
ID int `json:"id" xml:"id,attr"`
CreatedOn string `json:"created_on" xml:"createdOn"`
@@ -114,6 +130,7 @@ type SourceProvider struct {
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
}
// ServiceContentItem represents a media content item with source and location details.
type ServiceContentItem struct {
ID string `json:"id" xml:"id,attr"`
Name string `json:"name" xml:"itemName"`
@@ -125,6 +142,7 @@ type ServiceContentItem struct {
IsPresetable string `json:"is_presetable,omitempty" xml:"isPresetable,attr,omitempty"`
}
// ServicePreset represents a user-defined preset for quick access to media content.
type ServicePreset struct {
ServiceContentItem
ContainerArt string `json:"container_art" xml:"containerArt"`
@@ -132,6 +150,7 @@ type ServicePreset struct {
UpdatedOn string `json:"updated_on" xml:"updatedOn"`
}
// ServiceRecent represents recently played media content.
type ServiceRecent struct {
ServiceContentItem
DeviceID string `json:"device_id" xml:"deviceid"`
@@ -139,6 +158,7 @@ type ServiceRecent struct {
ContainerArt string `json:"container_art,omitempty" xml:"containerArt,omitempty"`
}
// 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"`
@@ -148,6 +168,7 @@ type ConfiguredSource struct {
SourceKeyAccount string `json:"source_key_account" xml:"username"`
}
// ServiceDeviceInfo represents information about a SoundTouch device.
type ServiceDeviceInfo struct {
DeviceID string `json:"device_id" xml:"deviceID,attr"`
ProductCode string `json:"product_code" xml:"type"`
@@ -158,6 +179,7 @@ type ServiceDeviceInfo struct {
Name string `json:"name" xml:"name"`
}
// CustomerSupportDevice represents device information for customer support purposes.
type CustomerSupportDevice struct {
ID string `xml:"id,attr"`
SerialNumber string `xml:"serialnumber"`
@@ -169,6 +191,7 @@ type CustomerSupportDevice struct {
} `xml:"product"`
}
// CustomerSupportRequest represents a customer support request with device and configuration details.
type CustomerSupportRequest struct {
XMLName xml.Name `xml:"device-data"`
Device CustomerSupportDevice `xml:"device"`
@@ -183,6 +206,7 @@ type CustomerSupportRequest struct {
} `xml:"diagnostic-data"`
}
// UsageStats represents usage statistics for the service.
type UsageStats struct {
DeviceID string `json:"deviceId" xml:"deviceId"`
AccountID string `json:"accountId" xml:"accountId"`
@@ -191,6 +215,7 @@ type UsageStats struct {
Parameters map[string]interface{} `json:"parameters" xml:"parameters"`
}
// ErrorStats represents error statistics for monitoring and debugging.
type ErrorStats struct {
DeviceID string `json:"deviceId" xml:"deviceId"`
ErrorCode string `json:"errorCode" xml:"errorCode"`
@@ -199,6 +224,7 @@ type ErrorStats struct {
Details string `json:"details,omitempty" xml:"details,omitempty"`
}
// DeviceEvent represents an event that occurred on a device.
type DeviceEvent struct {
Type string `json:"type"`
Time string `json:"time"`
+22
View File
@@ -28,6 +28,7 @@ type DataStore struct {
}
// NewDataStore creates a new DataStore.
// NewDataStore creates a new DataStore instance with the specified data directory.
func NewDataStore(dataDir string) *DataStore {
if dataDir == "" {
dataDir = "data"
@@ -39,18 +40,22 @@ 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)
}
// 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)
}
// AccountDeviceDir returns the directory path for a specific device within an account.
func (ds *DataStore) AccountDeviceDir(account, device string) string {
return filepath.Join(ds.AccountDevicesDir(account), device)
}
// GetDeviceInfo retrieves device information for the specified account and device.
func (ds *DataStore) GetDeviceInfo(account, device string) (*models.ServiceDeviceInfo, error) {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.DeviceInfoFile)
@@ -245,6 +250,7 @@ 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)
@@ -297,6 +303,7 @@ 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)
@@ -350,6 +357,7 @@ 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)
@@ -402,6 +410,7 @@ 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)
@@ -460,6 +469,7 @@ func (ds *DataStore) SaveRecents(account string, recents []models.ServiceRecent)
return os.WriteFile(path, append(header, data...), 0644)
}
// SaveDeviceInfo saves device information for the specified account and device.
func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.ServiceDeviceInfo) error {
if device == "" {
return fmt.Errorf("device ID/name cannot be empty")
@@ -541,11 +551,13 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
return os.WriteFile(path, append(header, data...), 0644)
}
// RemoveDevice removes a device and all its data from the specified account.
func (ds *DataStore) RemoveDevice(account, device string) error {
dir := ds.AccountDeviceDir(account, device)
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)
@@ -595,6 +607,7 @@ func (ds *DataStore) GetConfiguredSources(account string) ([]models.ConfiguredSo
return 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)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
@@ -641,6 +654,7 @@ func (ds *DataStore) SaveConfiguredSources(account string, sources []models.Conf
return os.WriteFile(path, append(header, data...), 0644)
}
// Initialize creates the necessary directory structure for the datastore.
func (ds *DataStore) Initialize() error {
// Ensure base data directory exists
if err := os.MkdirAll(ds.DataDir, 0755); err != nil {
@@ -661,6 +675,7 @@ 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)
@@ -672,6 +687,7 @@ 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)
@@ -683,6 +699,7 @@ 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)
@@ -694,6 +711,7 @@ 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)
@@ -711,6 +729,7 @@ func (ds *DataStore) GetETagForAccount(account string) int64 {
return maxETag
}
// SaveUsageStats saves usage statistics to the datastore.
func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error {
dir := filepath.Join(ds.DataDir, "stats", "usage")
if err := os.MkdirAll(dir, 0755); err != nil {
@@ -728,6 +747,7 @@ func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error {
return os.WriteFile(path, data, 0644)
}
// SaveErrorStats saves error statistics to the datastore.
func (ds *DataStore) SaveErrorStats(stats models.ErrorStats) error {
dir := filepath.Join(ds.DataDir, "stats", "error")
if err := os.MkdirAll(dir, 0755); err != nil {
@@ -745,6 +765,7 @@ func (ds *DataStore) SaveErrorStats(stats models.ErrorStats) error {
return os.WriteFile(path, data, 0644)
}
// AddDeviceEvent adds a device event to the in-memory event store.
func (ds *DataStore) AddDeviceEvent(deviceID string, event models.DeviceEvent) {
ds.eventMutex.Lock()
defer ds.eventMutex.Unlock()
@@ -760,6 +781,7 @@ func (ds *DataStore) AddDeviceEvent(deviceID string, event models.DeviceEvent) {
ds.deviceEvents[deviceID] = events
}
// GetDeviceEvents retrieves all events for the specified device.
func (ds *DataStore) GetDeviceEvents(deviceID string) []models.DeviceEvent {
ds.eventMutex.RLock()
defer ds.eventMutex.RUnlock()
+18 -17
View File
@@ -1,3 +1,5 @@
// Package marge provides XML generation and data management for the Marge service,
// which handles SoundTouch device configuration, presets, recents, and account management.
package marge
import (
@@ -12,8 +14,10 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// DateStr is a fixed timestamp used in XML responses for consistency.
const DateStr = "2012-09-19T12:43:00.000+00:00"
// SourceProviders returns a list of available media source providers.
func SourceProviders() []models.SourceProvider {
providers := make([]models.SourceProvider, len(constants.Providers))
for i, name := range constants.Providers {
@@ -28,11 +32,13 @@ func SourceProviders() []models.SourceProvider {
return providers
}
// SourceProvidersXML represents the XML structure for source providers.
type SourceProvidersXML struct {
XMLName xml.Name `xml:"sourceProviders"`
Providers []models.SourceProvider `xml:"sourceProvider"`
}
// SourceProvidersToXML converts source providers to XML format.
func SourceProvidersToXML() ([]byte, error) {
sp := SourceProvidersXML{
Providers: SourceProviders(),
@@ -46,6 +52,7 @@ func SourceProvidersToXML() ([]byte, error) {
return append([]byte(xml.Header), data...), nil
}
// ConfiguredSourceToXML converts a configured source to XML format.
func ConfiguredSourceToXML(cs models.ConfiguredSource) ([]byte, error) {
type SourceXML struct {
XMLName xml.Name `xml:"source"`
@@ -89,6 +96,7 @@ func ConfiguredSourceToXML(cs models.ConfiguredSource) ([]byte, error) {
return xml.Marshal(sxml)
}
// GetConfiguredSourceXML returns the XML representation of a configured source as a string.
func GetConfiguredSourceXML(cs models.ConfiguredSource) string {
providerID := 0
@@ -103,6 +111,7 @@ func GetConfiguredSourceXML(cs models.ConfiguredSource) string {
cs.ID, DateStr, cs.Secret, cs.SourceKeyAccount, providerID, cs.DisplayName, DateStr, cs.SourceKeyAccount)
}
// PresetsToXML converts account presets to XML format for Marge responses.
func PresetsToXML(ds *datastore.DataStore, account string) ([]byte, error) {
presets, err := ds.GetPresets(account)
if err != nil {
@@ -126,21 +135,13 @@ func PresetsToXML(ds *datastore.DataStore, account string) ([]byte, error) {
res += fmt.Sprintf(`<name>%s</name>`, p.Name)
// Content Item Source
found := false
for _, s := range sources {
if s.ID == p.SourceID || (s.SourceKeyType == p.Source && s.SourceKeyAccount == p.SourceAccount) {
res += GetConfiguredSourceXML(s)
found = true
break
}
}
if !found {
// This might happen if source is not found
}
res += fmt.Sprintf(`<updatedOn>%s</updatedOn>`, DateStr)
res += `</preset>`
}
@@ -150,6 +151,7 @@ func PresetsToXML(ds *datastore.DataStore, account string) ([]byte, error) {
return append([]byte(xml.Header), []byte(res)...), nil
}
// RecentsToXML converts account recent items to XML format for Marge responses.
func RecentsToXML(ds *datastore.DataStore, account string) ([]byte, error) {
recents, err := ds.GetRecents(account)
if err != nil {
@@ -179,21 +181,13 @@ func RecentsToXML(ds *datastore.DataStore, account string) ([]byte, error) {
res += fmt.Sprintf(`<name>%s</name>`, r.Name)
// Content Item Source
found := false
for _, s := range sources {
if s.ID == r.SourceID || (s.SourceKeyType == r.Source && s.SourceKeyAccount == r.SourceAccount) {
res += GetConfiguredSourceXML(s)
found = true
break
}
}
if !found {
// This might happen if source is not found
}
res += fmt.Sprintf(`<updatedOn>%s</updatedOn>`, DateStr)
res += `</recent>`
}
@@ -203,14 +197,17 @@ func RecentsToXML(ds *datastore.DataStore, account string) ([]byte, error) {
return append([]byte(xml.Header), []byte(res)...), nil
}
// ProviderSettingsToXML generates provider settings XML for the specified account.
func ProviderSettingsToXML(account string) string {
return fmt.Sprintf(`<providerSettings><providerSetting><boseId>%s</boseId><keyName>ELIGIBLE_FOR_TRIAL</keyName><value>true</value><providerId>14</providerId></providerSetting></providerSettings>`, account)
}
// SoftwareUpdateToXML generates software update configuration XML.
func SoftwareUpdateToXML() string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><software_update><softwareUpdateLocation></softwareUpdateLocation></software_update>`
}
// AccountFullToXML generates a complete account XML with devices, presets, and recents.
func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
devicesDir := ds.AccountDevicesDir(account)
@@ -275,7 +272,8 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
return []byte(res), nil
}
func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber int, sourceXML []byte) ([]byte, error) {
// UpdatePreset updates or creates a preset for the specified account and device.
func UpdatePreset(ds *datastore.DataStore, account, _ string, presetNumber int, sourceXML []byte) ([]byte, error) {
sources, err := ds.GetConfiguredSources(account)
if err != nil {
return nil, err
@@ -351,6 +349,7 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
return append([]byte(xml.Header), []byte(res)...), nil
}
// AddRecent adds or updates a recent item for the specified account and device.
func AddRecent(ds *datastore.DataStore, account, device string, sourceXML []byte) ([]byte, error) {
sources, err := ds.GetConfiguredSources(account)
if err != nil {
@@ -476,6 +475,7 @@ func formatRecentResponse(recentObj *models.ServiceRecent, matchingSrc *models.C
return append([]byte(xml.Header), []byte(res)...)
}
// AddDeviceToAccount adds a new device to the specified account.
func AddDeviceToAccount(ds *datastore.DataStore, account string, sourceXML []byte) ([]byte, error) {
var newDeviceElem struct {
DeviceID string `xml:"deviceid,attr"`
@@ -506,6 +506,7 @@ func AddDeviceToAccount(ds *datastore.DataStore, account string, sourceXML []byt
return append([]byte(xml.Header), []byte(res)...), nil
}
// RemoveDeviceFromAccount removes a device from the specified account.
func RemoveDeviceFromAccount(ds *datastore.DataStore, account, device string) error {
return ds.RemoveDevice(account, device)
}
+109 -114
View File
@@ -4,7 +4,6 @@ package setup
import (
"encoding/xml"
"fmt"
"log"
"net"
"net/http"
@@ -119,7 +118,59 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
SSHSuccess: false,
}
// 0. Populate from datastore if available
// Populate device info from datastore and live info
m.populateDeviceInfo(summary, deviceIP)
// 1. Initial planned config
plannedCfg := PrivateCfg{
MargeServerUrl: fmt.Sprintf("%s/marge", targetURL),
StatsServerUrl: targetURL,
SwUpdateUrl: fmt.Sprintf("%s/updates/soundtouch", targetURL),
UsePandoraProductionServer: true,
IsZeroconfEnabled: true,
SaveMargeCustomerReport: false,
BmxRegistryUrl: fmt.Sprintf("%s/bmx/registry/v1/services", targetURL),
}
// 2. Check SSH and read current config
currentConfig, err := m.checkCurrentConfig(summary, deviceIP)
if err == nil && currentConfig != "" {
summary.CurrentConfig = currentConfig
fmt.Printf("Current config from %s (length: %d):\n%q\n", deviceIP, len(currentConfig), currentConfig)
// Parse current config
var currentCfg PrivateCfg
if xml.Unmarshal([]byte(currentConfig), &currentCfg) == nil {
summary.ParsedCurrentConfig = &currentCfg
if proxyURL == "" {
proxyURL = targetURL
}
// Apply options if provided
if options != nil {
m.applyProxyOptions(&plannedCfg, proxyURL, options, &currentCfg)
}
}
}
// Note: CurrentConfig is set by checkCurrentConfig in all cases (success or failure)
xmlContent, err := xml.MarshalIndent(plannedCfg, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal planned XML: %w", err)
}
summary.PlannedConfig = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" + string(xmlContent)
// 3. Check for remote services files
m.checkRemoteServices(summary, deviceIP)
return summary, nil
}
// populateDeviceInfo fills in device information from datastore and live info
func (m *Manager) populateDeviceInfo(summary *MigrationSummary, deviceIP string) {
// Populate from datastore if available
if m.DataStore != nil {
devices, err := m.DataStore.ListAllDevices()
if err == nil {
@@ -135,14 +186,11 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
break
}
} else {
log.Printf("Warning: failed to list devices from datastore: %v", err)
}
}
// 0a. Supplement with live info from :8090/info
infoXML, err := m.GetLiveDeviceInfo(deviceIP)
if err == nil {
// Supplement with live info from :8090/info
if infoXML, err := m.GetLiveDeviceInfo(deviceIP); err == nil {
if infoXML.Name != "" {
summary.DeviceName = infoXML.Name
}
@@ -158,119 +206,83 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
if infoXML.SoftwareVer != "" {
summary.FirmwareVersion = infoXML.SoftwareVer
}
} else {
log.Printf("Warning: %v", err)
}
}
// 1. Initial planned config
plannedCfg := PrivateCfg{
MargeServerUrl: fmt.Sprintf("%s/marge", targetURL),
StatsServerUrl: targetURL,
SwUpdateUrl: fmt.Sprintf("%s/updates/soundtouch", targetURL),
UsePandoraProductionServer: true,
IsZeroconfEnabled: true,
SaveMargeCustomerReport: false,
BmxRegistryUrl: fmt.Sprintf("%s/bmx/registry/v1/services", targetURL),
}
// 2. Check SSH and read current config
var currentConfig string
// checkCurrentConfig reads and validates the current speaker configuration
func (m *Manager) checkCurrentConfig(summary *MigrationSummary, deviceIP string) (string, error) {
path := SoundTouchSdkPrivateCfgPath
client := ssh.NewClient(deviceIP)
// Check if .original exists
if _, checkErr := client.Run(fmt.Sprintf("[ -f %s.original ]", path)); checkErr == nil {
originalConfig, _ := client.Run(fmt.Sprintf("cat %s.original", path))
if originalConfig != "" {
if originalConfig, _ := client.Run(fmt.Sprintf("cat %s.original", path)); originalConfig != "" {
summary.OriginalConfig = originalConfig
}
}
// Check file details
fileInfo, _ := client.Run(fmt.Sprintf("ls -l %s", path))
if fileInfo != "" {
fmt.Printf("File info for %s: %s\n", path, fileInfo)
}
// Try cat
// Try to read current config
config, err := client.Run(fmt.Sprintf("cat %s", path))
if err == nil && config != "" {
currentConfig = config
summary.SSHSuccess = true
summary.CurrentConfig = currentConfig
fmt.Printf("Current config from %s at %s (length: %d):\n%q\n", deviceIP, path, len(currentConfig), currentConfig)
return config, nil
}
// Parse current config
var currentCfg PrivateCfg
if xml.Unmarshal([]byte(currentConfig), &currentCfg) == nil {
summary.ParsedCurrentConfig = &currentCfg
// Fallback: try base64 if cat returned empty string but file has size > 0
if config == "" {
if fileInfo, _ := client.Run(fmt.Sprintf("ls -l %s", path)); fileInfo != "" {
if b64Config, configErr := client.Run(fmt.Sprintf("base64 %s", path)); configErr == nil && b64Config != "" {
// File exists but couldn't read content properly
summary.SSHSuccess = true
summary.CurrentConfig = fmt.Sprintf("Error reading config: %v", err)
if proxyURL == "" {
proxyURL = targetURL
return "", fmt.Errorf("config file exists but couldn't read content")
}
}
}
// Apply options if provided
if options != nil {
// Marge
if options["marge"] == "original" {
plannedCfg.MargeServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.MargeServerUrl)
}
// Stats
if options["stats"] == "original" {
plannedCfg.StatsServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.StatsServerUrl)
}
// SwUpdate
if options["sw_update"] == "original" {
plannedCfg.SwUpdateUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.SwUpdateUrl)
}
// BMX
if options["bmx"] == "original" {
plannedCfg.BmxRegistryUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.BmxRegistryUrl)
}
} else if proxyURL != "" {
// Default to proxy everything if proxyURL is explicitly provided but no options
// (Maintain backward compatibility for now if needed, but we'll probably always pass options from UI)
// Actually, if proxyURL is set but no options, let's keep the previous behavior of proxying all.
plannedCfg.MargeServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.MargeServerUrl)
plannedCfg.StatsServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.StatsServerUrl)
plannedCfg.SwUpdateUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.SwUpdateUrl)
plannedCfg.BmxRegistryUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.BmxRegistryUrl)
}
// If SSH failed or file couldn't be read, check if SSH connection works at all
if _, sshErr := client.Run("ls /"); sshErr == nil {
summary.SSHSuccess = true
if err != nil {
summary.CurrentConfig = fmt.Sprintf("Error reading config: %v", err)
} else {
summary.CurrentConfig = config // Might be empty
}
} else {
// Fallback: try base64 if cat returned empty string but file has size > 0
if config == "" && fileInfo != "" {
fmt.Printf("Cat returned empty for %s, trying base64\n", path)
b64Config, configErr := client.Run(fmt.Sprintf("base64 %s", path))
if configErr == nil && b64Config != "" {
fmt.Printf("Base64 output for %s (length %d)\n", path, len(b64Config))
}
}
// If SSH failed or file couldn't be read
if _, sshErr := client.Run("ls /"); sshErr == nil {
summary.SSHSuccess = true
if err != nil {
summary.CurrentConfig = fmt.Sprintf("Error reading config: %v", err)
} else {
summary.CurrentConfig = config // Might be empty
}
} else {
summary.SSHSuccess = false
summary.CurrentConfig = fmt.Sprintf("SSH connection failed: %v", sshErr)
}
summary.SSHSuccess = false
summary.CurrentConfig = fmt.Sprintf("SSH connection failed: %v", sshErr)
}
xmlContent, err := xml.MarshalIndent(plannedCfg, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal planned XML: %w", err)
return "", err
}
// applyProxyOptions modifies planned config based on proxy options
func (m *Manager) applyProxyOptions(plannedCfg *PrivateCfg, proxyURL string, options map[string]string, currentCfg *PrivateCfg) {
if proxyURL == "" || currentCfg == nil {
return
}
summary.PlannedConfig = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" + string(xmlContent)
if options["marge"] == "original" && currentCfg.MargeServerUrl != "" {
plannedCfg.MargeServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.MargeServerUrl)
}
// 3. Check for remote services files
if options["stats"] == "original" && currentCfg.StatsServerUrl != "" {
plannedCfg.StatsServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.StatsServerUrl)
}
if options["sw_update"] == "original" && currentCfg.SwUpdateUrl != "" {
plannedCfg.SwUpdateUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.SwUpdateUrl)
}
if options["bmx"] == "original" && currentCfg.BmxRegistryUrl != "" {
plannedCfg.BmxRegistryUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.BmxRegistryUrl)
}
}
// checkRemoteServices checks for remote services files on the device
func (m *Manager) checkRemoteServices(summary *MigrationSummary, deviceIP string) {
client := ssh.NewClient(deviceIP)
locations := []string{
"/etc/remote_services",
"/mnt/nv/remote_services",
@@ -278,8 +290,7 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
}
for _, loc := range locations {
_, err := client.Run(fmt.Sprintf("[ -e %s ]", loc))
if err == nil {
if _, err := client.Run(fmt.Sprintf("[ -e %s ]", loc)); err == nil {
summary.RemoteServicesFound = append(summary.RemoteServicesFound, loc)
summary.RemoteServicesEnabled = true
@@ -288,8 +299,6 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
}
}
}
return summary, nil
}
// MigrateSpeaker configures the speaker at the given IP to use this soundcork service.
@@ -324,21 +333,7 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
}
if options != nil {
if options["marge"] == "original" {
cfg.MargeServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.MargeServerUrl)
}
if options["stats"] == "original" {
cfg.StatsServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.StatsServerUrl)
}
if options["sw_update"] == "original" {
cfg.SwUpdateUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.SwUpdateUrl)
}
if options["bmx"] == "original" {
cfg.BmxRegistryUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.BmxRegistryUrl)
}
m.applyProxyOptions(&cfg, proxyURL, options, &currentCfg)
} else if proxyURL != "" {
cfg.MargeServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.MargeServerUrl)
cfg.StatsServerUrl = fmt.Sprintf("%s/proxy/%s", proxyURL, currentCfg.StatsServerUrl)