mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1288a619f7 |
@@ -26,8 +26,6 @@ A comprehensive solution for controlling and preserving Bose SoundTouch devices,
|
||||
- 📊 **DNS Discovery Analysis**: Track and deduplicate all device DNS queries to discover hidden hostnames
|
||||
- 📊 **Traffic Analysis**: Proxy and log device communications
|
||||
- 📝 **HTTP Recording**: Persist interactions as re-playable `.http` files
|
||||
- 🔄 **Endpoint Mirroring**: Asynchronously mirror local requests to Bose cloud for parity testing
|
||||
- ⚖️ **Parity Logging**: Detect and record discrepancies between local and official Bose responses
|
||||
- 🧹 **Session Management**: Manage and cleanup recorded interaction sessions
|
||||
- 🔒 **Production Ready**: Extensive testing with real SoundTouch hardware
|
||||
- 🌐 **Cross-Platform**: Windows, macOS, Linux support
|
||||
@@ -78,9 +76,8 @@ The `soundtouch-service` is a local server that emulates Bose's cloud services.
|
||||
- **🔌 Easy Setup**: Activate SSH via USB stick (`remote_services` file)
|
||||
- **🔧 Device Migration**: Seamlessly transition devices to local control
|
||||
- **🌐 Web Management UI**: Easy browser-based setup and management
|
||||
- **🎮 Stockholm Mini**: A minimal reverse-engineered UI for device control (accessible at `/web/stockholm-mini/`)
|
||||
- **💾 Persistent Data**: Store presets, recents, and sources locally
|
||||
- **🔄 Endpoint Mirroring**: Asynchronously mirror local requests to Bose cloud for parity testing
|
||||
- **⚖️ Parity Logging**: Detect and record discrepancies between local and official Bose responses
|
||||
- **📝 HTTP Recording**: Persist all interactions as re-playable `.http` files
|
||||
- **🧹 Session Management**: Manage and cleanup recorded interaction sessions
|
||||
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
// Package main provides a debug tool for analyzing device consolidation and migration scenarios.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("Usage: debug-consolidation <data-directory>")
|
||||
fmt.Println("Example: debug-consolidation /var/lib/soundtouch-service")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
dataDir := os.Args[1]
|
||||
|
||||
fmt.Printf("🔍 Analyzing device consolidation in: %s\n", dataDir)
|
||||
|
||||
// Initialize datastore
|
||||
ds := datastore.NewDataStore(dataDir)
|
||||
|
||||
// List all devices
|
||||
devices, err := ds.ListAllDevices()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to list devices: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("📱 Found %d device entries:\n", len(devices))
|
||||
|
||||
for i := range devices {
|
||||
device := &devices[i]
|
||||
fmt.Printf(" %d. %s (Account: %s)\n", i+1, device.DeviceID, device.AccountID)
|
||||
fmt.Printf(" Name: %s\n", device.Name)
|
||||
fmt.Printf(" IP: %s, MAC: %s, Serial: %s\n",
|
||||
device.IPAddress, device.MacAddress, device.DeviceSerialNumber)
|
||||
|
||||
// Check directory contents
|
||||
deviceDir := ds.AccountDeviceDir(device.AccountID, device.DeviceID)
|
||||
analyzeDeviceDirectory(deviceDir, device.DeviceID)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Group devices by potential physical device
|
||||
fmt.Println("🔄 Analyzing potential consolidation opportunities:")
|
||||
|
||||
deviceGroups := groupDevicesByIdentity(devices)
|
||||
|
||||
for i, group := range deviceGroups {
|
||||
if len(group) <= 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf(" Group %d - %d entries for same physical device:\n", i+1, len(group))
|
||||
|
||||
for i := range group {
|
||||
device := &group[i]
|
||||
deviceDir := ds.AccountDeviceDir(device.AccountID, device.DeviceID)
|
||||
fileCount := countFiles(deviceDir)
|
||||
fmt.Printf(" - %s (%d files)\n", device.DeviceID, fileCount)
|
||||
}
|
||||
|
||||
// Recommend consolidation target
|
||||
macDevice := findMACBasedDevice(group)
|
||||
if macDevice != nil {
|
||||
fmt.Printf(" → Recommend keeping: %s (MAC-based)\n", macDevice.DeviceID)
|
||||
} else {
|
||||
fmt.Printf(" → No clear MAC-based target found\n")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func analyzeDeviceDirectory(dirPath, deviceID string) {
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
fmt.Printf(" Directory: %s (Error: %v)\n", dirPath, err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" Directory: %s (%d files)\n", dirPath, len(entries))
|
||||
|
||||
// Check for important files
|
||||
importantFiles := []string{"DeviceInfo.xml", "Presets.xml", "Recents.xml", "Sources.xml"}
|
||||
for _, fileName := range importantFiles {
|
||||
filePath := filepath.Join(dirPath, fileName)
|
||||
if stat, err := os.Stat(filePath); err == nil {
|
||||
status := "✓"
|
||||
if stat.Size() == 0 {
|
||||
status = "⚠️ (empty)"
|
||||
} else if stat.Size() < 100 {
|
||||
status = "⚠️ (very small)"
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s (%d bytes)\n", status, fileName, stat.Size())
|
||||
} else {
|
||||
fmt.Printf(" ❌ %s (missing)\n", fileName)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if deviceID looks like MAC address
|
||||
if isLikelyMACAddress(deviceID) {
|
||||
fmt.Printf(" 📍 Device ID appears to be MAC address format\n")
|
||||
} else {
|
||||
fmt.Printf(" 📍 Device ID appears to be %s format\n", guessIDType(deviceID))
|
||||
}
|
||||
}
|
||||
|
||||
func countFiles(dirPath string) int {
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
count := 0
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
func groupDevicesByIdentity(devices []models.ServiceDeviceInfo) [][]models.ServiceDeviceInfo {
|
||||
var groups [][]models.ServiceDeviceInfo
|
||||
|
||||
// Simple grouping by MAC address and serial number
|
||||
macGroups := make(map[string][]models.ServiceDeviceInfo)
|
||||
serialGroups := make(map[string][]models.ServiceDeviceInfo)
|
||||
ipGroups := make(map[string][]models.ServiceDeviceInfo)
|
||||
|
||||
for i := range devices {
|
||||
device := &devices[i]
|
||||
// Group by MAC address
|
||||
if device.MacAddress != "" {
|
||||
macGroups[device.MacAddress] = append(macGroups[device.MacAddress], *device)
|
||||
}
|
||||
|
||||
// Group by serial number
|
||||
if device.DeviceSerialNumber != "" {
|
||||
serialGroups[device.DeviceSerialNumber] = append(serialGroups[device.DeviceSerialNumber], *device)
|
||||
}
|
||||
|
||||
// Group by IP address
|
||||
if device.IPAddress != "" {
|
||||
ipGroups[device.IPAddress] = append(ipGroups[device.IPAddress], *device)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge groups - prioritize MAC address grouping
|
||||
processed := make(map[string]bool)
|
||||
|
||||
for _, macDevices := range macGroups {
|
||||
if len(macDevices) > 1 {
|
||||
groups = append(groups, macDevices)
|
||||
for i := range macDevices {
|
||||
processed[macDevices[i].DeviceID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for serial number groups not already processed
|
||||
for _, serialDevices := range serialGroups {
|
||||
if len(serialDevices) > 1 {
|
||||
unprocessed := []models.ServiceDeviceInfo{}
|
||||
|
||||
for i := range serialDevices {
|
||||
if !processed[serialDevices[i].DeviceID] {
|
||||
unprocessed = append(unprocessed, serialDevices[i])
|
||||
}
|
||||
}
|
||||
|
||||
if len(unprocessed) > 1 {
|
||||
groups = append(groups, unprocessed)
|
||||
for i := range unprocessed {
|
||||
processed[unprocessed[i].DeviceID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
func findMACBasedDevice(devices []models.ServiceDeviceInfo) *models.ServiceDeviceInfo {
|
||||
for i := range devices {
|
||||
if isLikelyMACAddress(devices[i].DeviceID) {
|
||||
return &devices[i]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isLikelyMACAddress(id string) bool {
|
||||
// MAC addresses are typically 12 hex characters without separators
|
||||
// or 17 characters with separators (XX:XX:XX:XX:XX:XX)
|
||||
if len(id) == 12 {
|
||||
for _, c := range id {
|
||||
if (c < '0' || c > '9') && (c < 'A' || c > 'F') && (c < 'a' || c > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func guessIDType(id string) string {
|
||||
if len(id) > 15 && (id[0] == 'I' || id[0] == 'K') {
|
||||
return "serial number"
|
||||
}
|
||||
|
||||
// Check if it looks like an IP address
|
||||
if len(id) >= 7 && len(id) <= 15 {
|
||||
dotCount := 0
|
||||
|
||||
for _, c := range id {
|
||||
if c == '.' {
|
||||
dotCount++
|
||||
} else if c < '0' || c > '9' {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if dotCount == 3 {
|
||||
return "IP address"
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
+84
-263
@@ -8,7 +8,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -147,8 +146,8 @@ func main() {
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dns-upstream",
|
||||
Usage: "Upstream DNS server(s) for non-Bose queries (comma-separated). If empty, /etc/resolv.conf is used.",
|
||||
Value: "",
|
||||
Usage: "Upstream DNS server for non-Bose queries",
|
||||
Value: "8.8.8.8",
|
||||
EnvVars: []string{"DNS_UPSTREAM"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
@@ -190,32 +189,6 @@ func main() {
|
||||
Usage: "External base URL for OAuth callbacks behind reverse proxy",
|
||||
EnvVars: []string{"BASE_URL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "mirror-enabled",
|
||||
Usage: "Enable background mirroring to Bose Cloud",
|
||||
EnvVars: []string{"MIRROR_ENABLED"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "mirror-endpoints",
|
||||
Usage: "Endpoints to mirror to Bose Cloud (comma-separated or multiple flags)",
|
||||
EnvVars: []string{"MIRROR_ENDPOINTS"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "internal-paths",
|
||||
Usage: "Paths for internal requests (comma-separated or multiple flags)",
|
||||
EnvVars: []string{"INTERNAL_PATHS"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migration-enabled",
|
||||
Usage: "Enable device directory migration from serial to MAC-based structure",
|
||||
Value: true,
|
||||
EnvVars: []string{"MIGRATION_ENABLED"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migration-dry-run",
|
||||
Usage: "Log what would be migrated without actually doing it",
|
||||
EnvVars: []string{"MIGRATION_DRY_RUN"},
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
config := loadConfig(c)
|
||||
@@ -238,19 +211,16 @@ func main() {
|
||||
|
||||
cm := initCertificateManager(config.dataDir)
|
||||
sm := setup.NewManager(config.serverURL, ds, cm)
|
||||
sm.MgmtUsername = config.mgmtUsername
|
||||
sm.MgmtPassword = config.mgmtPassword
|
||||
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record, config.enableSoundcorkProxy, config.migrationEnabled, config.migrationDryRun)
|
||||
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record, config.enableSoundcorkProxy)
|
||||
sm.GetDNSRunning = server.GetDNSRunning
|
||||
server.SetSoundcorkURL(config.soundcorkURL)
|
||||
server.SetHTTPServerURL(config.httpsServerURL)
|
||||
server.SetVersionInfo(version, commit, date)
|
||||
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
|
||||
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
|
||||
server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints)
|
||||
server.SetInternalPaths(persisted.InternalPaths)
|
||||
server.SetDNSSettings(persisted.DNSEnabled, persisted.DNSUpstream, persisted.DNSBindAddr)
|
||||
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
|
||||
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
|
||||
server.SetBaseURL(config.baseURL)
|
||||
|
||||
if config.spotifyClientID != "" {
|
||||
spotifyService := spotify.NewSpotifyService(
|
||||
@@ -380,9 +350,6 @@ type serviceConfig struct {
|
||||
dnsEnabled bool
|
||||
dnsUpstream string
|
||||
dnsBind string
|
||||
mirrorEnabled bool
|
||||
mirrorEndpoints []string
|
||||
internalPaths []string
|
||||
discoveryInterval time.Duration
|
||||
domains []string
|
||||
spotifyClientID string
|
||||
@@ -390,8 +357,7 @@ type serviceConfig struct {
|
||||
spotifyRedirectURI string
|
||||
mgmtUsername string
|
||||
mgmtPassword string
|
||||
migrationEnabled bool
|
||||
migrationDryRun bool
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func loadConfig(c *cli.Context) serviceConfig {
|
||||
@@ -455,11 +421,7 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
spotifyRedirectURI := c.String("spotify-redirect-uri")
|
||||
mgmtUsername := c.String("mgmt-username")
|
||||
mgmtPassword := c.String("mgmt-password")
|
||||
mirrorEnabled := c.Bool("mirror-enabled")
|
||||
mirrorEndpoints := c.StringSlice("mirror-endpoints")
|
||||
internalPaths := c.StringSlice("internal-paths")
|
||||
migrationEnabled := c.Bool("migration-enabled")
|
||||
migrationDryRun := c.Bool("migration-dry-run")
|
||||
baseURL := c.String("base-url")
|
||||
|
||||
return serviceConfig{
|
||||
port: port,
|
||||
@@ -477,9 +439,6 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
dnsEnabled: dnsEnabled,
|
||||
dnsUpstream: dnsUpstream,
|
||||
dnsBind: dnsBind,
|
||||
mirrorEnabled: mirrorEnabled,
|
||||
mirrorEndpoints: mirrorEndpoints,
|
||||
internalPaths: internalPaths,
|
||||
discoveryInterval: discoveryInterval,
|
||||
domains: domains,
|
||||
spotifyClientID: spotifyClientID,
|
||||
@@ -487,30 +446,21 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
spotifyRedirectURI: spotifyRedirectURI,
|
||||
mgmtUsername: mgmtUsername,
|
||||
mgmtPassword: mgmtPassword,
|
||||
migrationEnabled: migrationEnabled,
|
||||
migrationDryRun: migrationDryRun,
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func getDomains(serverURL, httpsServerURL, hostname string) []string {
|
||||
domainsMap := map[string]bool{
|
||||
// RFC-compliant wildcards for API patterns
|
||||
"*.api.bose.io": true,
|
||||
"*.api.bosecm.com": true,
|
||||
// Core Bose domains (keep specific ones for clarity)
|
||||
"streaming.bose.com": true,
|
||||
"updates.bose.com": true,
|
||||
"stats.bose.com": true,
|
||||
"bmx.bose.com": true,
|
||||
"worldwide.bose.com": true,
|
||||
"music.api.bose.com": true,
|
||||
"bose-prod.apigee.net": true,
|
||||
"bose-test.apigee.net": true,
|
||||
// Local service domains
|
||||
setup.TestDomain: true,
|
||||
hostname: true,
|
||||
"localhost": true,
|
||||
"127.0.0.1": true,
|
||||
"streaming.bose.com": true,
|
||||
"updates.bose.com": true,
|
||||
"stats.bose.com": true,
|
||||
"bmx.bose.com": true,
|
||||
"content.api.bose.io": true,
|
||||
setup.TestDomain: true,
|
||||
hostname: true,
|
||||
"localhost": true,
|
||||
"127.0.0.1": true,
|
||||
}
|
||||
|
||||
if u, err := url.Parse(serverURL); err == nil && u.Hostname() != "" {
|
||||
@@ -559,18 +509,14 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
|
||||
config.enableSoundcorkProxy = persisted.EnableSoundcorkProxy
|
||||
|
||||
config.dnsEnabled = persisted.DNSEnabled
|
||||
if len(persisted.DNSUpstream) > 0 {
|
||||
config.dnsUpstream = strings.Join(persisted.DNSUpstream, ",")
|
||||
if persisted.DNSUpstream != "" {
|
||||
config.dnsUpstream = persisted.DNSUpstream
|
||||
}
|
||||
|
||||
if persisted.DNSBindAddr != "" {
|
||||
config.dnsBind = persisted.DNSBindAddr
|
||||
}
|
||||
|
||||
config.mirrorEnabled = persisted.MirrorEnabled
|
||||
config.mirrorEndpoints = persisted.MirrorEndpoints
|
||||
config.internalPaths = persisted.InternalPaths
|
||||
|
||||
return persisted
|
||||
}
|
||||
|
||||
@@ -586,11 +532,8 @@ func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datast
|
||||
DiscoveryEnabled: true,
|
||||
EnableSoundcorkProxy: config.enableSoundcorkProxy,
|
||||
DNSEnabled: config.dnsEnabled,
|
||||
DNSUpstream: strings.Split(config.dnsUpstream, ","),
|
||||
DNSUpstream: config.dnsUpstream,
|
||||
DNSBindAddr: config.dnsBind,
|
||||
MirrorEnabled: config.mirrorEnabled,
|
||||
MirrorEndpoints: config.mirrorEndpoints,
|
||||
InternalPaths: config.internalPaths,
|
||||
Shortcuts: map[string]int{
|
||||
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
|
||||
"/sw.js": http.StatusNotFound,
|
||||
@@ -637,7 +580,6 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Use(server.OriginMiddleware)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(server.ShortcutMiddleware)
|
||||
r.Use(server.MirrorMiddleware)
|
||||
r.Use(server.RecordMiddleware)
|
||||
|
||||
r.Get("/", server.HandleRoot)
|
||||
@@ -666,57 +608,40 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
|
||||
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
|
||||
|
||||
streamingRoutes := func(r chi.Router) {
|
||||
r.Get("/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Get("/account/{account}/device/{device}/recent", server.HandleMargeRecents)
|
||||
r.Post("/account/{account}/device/{device}/recent", server.HandleMargeAddRecent)
|
||||
r.Get("/account/{account}/device/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/account/{account}/device/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Post("/support/power_on", server.HandleMargePowerOn)
|
||||
r.Get("/account/{account}/provider_settings", server.HandleMargeProviderSettings)
|
||||
r.Get("/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
r.Post("/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
r.Get("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
r.Get("/account/{account}/device/{device}/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/account/{account}/device/{device}/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/account/{account}/device/{device}/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/account/{account}/device/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
r.Post("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
r.Get("/account/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Get("/software/update/account/{account}", server.HandleMargeSoftwareUpdate)
|
||||
|
||||
r.Route("/stats", func(r chi.Router) {
|
||||
r.Post("/usage", server.HandleUsageStats)
|
||||
r.Post("/error", server.HandleErrorStats)
|
||||
})
|
||||
}
|
||||
|
||||
accountsRoutes := func(r chi.Router) {
|
||||
r.Get("/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Get("/{account}/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Get("/{account}/devices/{device}/recents", server.HandleMargeRecents)
|
||||
r.Post("/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
|
||||
r.Post("/{account}/devices", server.HandleMargeAddDevice)
|
||||
r.Delete("/{account}/devices/{device}", server.HandleMargeRemoveDevice)
|
||||
r.Get("/{account}/devices/{device}/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/{account}/devices/{device}/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/{account}/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/{account}/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
}
|
||||
|
||||
r.Route("/marge", func(r chi.Router) {
|
||||
r.Route("/streaming", streamingRoutes)
|
||||
r.Route("/accounts", accountsRoutes)
|
||||
|
||||
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Post("/streaming/support/power_on", server.HandleMargePowerOn)
|
||||
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
|
||||
r.Get("/accounts/{account}/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/accounts/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Post("/accounts/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
|
||||
r.Post("/accounts/{account}/devices", server.HandleMargeAddDevice)
|
||||
r.Delete("/accounts/{account}/devices/{device}", server.HandleMargeRemoveDevice)
|
||||
r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
|
||||
r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
})
|
||||
|
||||
// Legacy or direct domain calls without /marge prefix
|
||||
r.Route("/streaming", streamingRoutes)
|
||||
r.Route("/accounts", accountsRoutes)
|
||||
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Post("/streaming/support/power_on", server.HandleMargePowerOn)
|
||||
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
|
||||
r.Get("/accounts/{account}/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/accounts/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Post("/accounts/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
|
||||
r.Post("/accounts/{account}/devices", server.HandleMargeAddDevice)
|
||||
r.Delete("/accounts/{account}/devices/{device}", server.HandleMargeRemoveDevice)
|
||||
r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
|
||||
r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
|
||||
r.Route("/customer", func(r chi.Router) {
|
||||
r.Get("/account/{account}", server.HandleMargeAccountProfile)
|
||||
@@ -729,6 +654,11 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Post("/scmudc/{deviceId}", server.HandleAppEvents)
|
||||
})
|
||||
|
||||
r.Route("/streaming/stats", func(r chi.Router) {
|
||||
r.Post("/usage", server.HandleUsageStats)
|
||||
r.Post("/error", server.HandleErrorStats)
|
||||
})
|
||||
|
||||
r.Route("/mgmt", func(r chi.Router) {
|
||||
// Browser OAuth callback — no auth required (Spotify redirects the
|
||||
// user's browser here directly). The authorization code is single-use,
|
||||
@@ -745,51 +675,59 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/spotify/accounts", server.HandleMgmtSpotifyAccounts)
|
||||
r.Get("/spotify/token", server.HandleMgmtSpotifyToken)
|
||||
r.Post("/spotify/entity", server.HandleMgmtSpotifyEntity)
|
||||
r.Post("/spotify/prime", server.HandleMgmtPrimeDevice)
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/proxy/*", server.HandleProxyRequest)
|
||||
|
||||
r.Route("/devices", func(r chi.Router) {
|
||||
r.Get("/", server.HandleListDiscoveredDevices)
|
||||
r.Post("/", server.HandleAddManualDevice)
|
||||
|
||||
r.Route("/{deviceId}", func(r chi.Router) {
|
||||
r.Delete("/", server.HandleRemoveDevice)
|
||||
r.Get("/events", server.HandleGetDeviceEvents)
|
||||
r.Get("/info", server.HandleGetDeviceInfo)
|
||||
r.Get("/ws", server.HandleDeviceWebSocket)
|
||||
r.Post("/key/{key}", server.HandleDeviceKey)
|
||||
r.Post("/volume/{level}", server.HandleDeviceVolume)
|
||||
r.Post("/reboot", server.HandleRebootDevice)
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/version", server.HandleGetVersionInfo)
|
||||
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
r.Get("/devices", server.HandleListDiscoveredDevices)
|
||||
r.Post("/devices", server.HandleAddManualDevice)
|
||||
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
|
||||
r.Post("/discover", server.HandleTriggerDiscovery)
|
||||
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
|
||||
r.Get("/settings", server.HandleGetSettings)
|
||||
r.Post("/settings", server.HandleUpdateSettings)
|
||||
r.Get("/info/{deviceId}", server.HandleGetDeviceInfo)
|
||||
r.Get("/summary/{deviceId}", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
|
||||
r.Post("/revert/{deviceId}", server.HandleRevertMigration)
|
||||
r.Post("/reboot/{deviceId}", server.HandleRebootDevice)
|
||||
r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services/{deviceId}", server.HandleRemoveRemoteServices)
|
||||
r.Post("/backup/{deviceId}", server.HandleBackupConfig)
|
||||
r.Post("/sync/{deviceId}", server.HandleInitialSync)
|
||||
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
|
||||
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
|
||||
r.Post("/test-dns/{deviceId}", server.HandleTestDNSRedirection)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
r.Get("/proxy-settings", server.HandleGetProxySettings)
|
||||
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
|
||||
r.Get("/version", server.HandleGetVersionInfo)
|
||||
r.Get("/interaction-stats", server.HandleGetInteractionStats)
|
||||
r.Get("/interactions", server.HandleListInteractions)
|
||||
r.Get("/interaction-content", server.HandleGetInteractionContent)
|
||||
r.Get("/parity-mismatches", server.HandleListParityMismatches)
|
||||
r.Delete("/parity-mismatches", server.HandleClearParityMismatches)
|
||||
r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession)
|
||||
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
|
||||
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
|
||||
|
||||
r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
|
||||
r.Get("/dns-discoveries/download", server.HandleDownloadDNSDiscoveries)
|
||||
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
|
||||
|
||||
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
|
||||
r.Route("/devices/{deviceId}", func(r chi.Router) {
|
||||
r.Get("/summary", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate", server.HandleMigrateDevice)
|
||||
r.Post("/revert", server.HandleRevertMigration)
|
||||
r.Post("/trust-ca", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services", server.HandleRemoveRemoteServices)
|
||||
r.Post("/backup", server.HandleBackupConfig)
|
||||
r.Post("/sync", server.HandleInitialSync)
|
||||
r.Post("/test-connection", server.HandleTestConnection)
|
||||
r.Post("/test-hosts", server.HandleTestHostsRedirection)
|
||||
r.Post("/test-dns", server.HandleTestDNSRedirection)
|
||||
})
|
||||
})
|
||||
|
||||
r.NotFound(server.HandleNotFound)
|
||||
@@ -798,134 +736,17 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
}
|
||||
|
||||
func startHTTPSServer(httpsAddr string, r http.Handler, tlsConfig *tls.Config, httpsServerURL string) {
|
||||
// Add custom error logging and connection state tracking
|
||||
tlsConfig.GetCertificate = func(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
log.Printf("[TLS] Certificate request for ServerName: %s", clientHello.ServerName)
|
||||
|
||||
// Use the default certificate selection logic
|
||||
for _, cert := range tlsConfig.Certificates {
|
||||
if cert.Leaf != nil {
|
||||
for _, name := range cert.Leaf.DNSNames {
|
||||
if matchesDomain(name, clientHello.ServerName) {
|
||||
log.Printf("[TLS] ✅ Serving certificate for %s (matched %s)", clientHello.ServerName, name)
|
||||
return &cert, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no specific match, return the first certificate and log it
|
||||
if len(tlsConfig.Certificates) > 0 {
|
||||
log.Printf("[TLS] ⚠️ No exact match for %s, using default certificate", clientHello.ServerName)
|
||||
return &tlsConfig.Certificates[0], nil
|
||||
}
|
||||
|
||||
log.Printf("[TLS] ❌ No certificate available for %s", clientHello.ServerName)
|
||||
|
||||
return nil, fmt.Errorf("no certificate available for %s", clientHello.ServerName)
|
||||
}
|
||||
|
||||
httpsServer := &http.Server{
|
||||
Addr: httpsAddr,
|
||||
Handler: r,
|
||||
TLSConfig: tlsConfig,
|
||||
ErrorLog: log.Default(), // Ensure error logging is enabled
|
||||
}
|
||||
|
||||
log.Printf("Go service starting HTTPS on %s", httpsServerURL)
|
||||
|
||||
go func() {
|
||||
listener, err := net.Listen("tcp", httpsAddr)
|
||||
if err != nil {
|
||||
log.Printf("[TLS] Failed to create listener: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
tlsListener := tls.NewListener(listener, tlsConfig)
|
||||
|
||||
// Wrap listener to log connection attempts
|
||||
wrappedListener := &loggingTLSListener{
|
||||
Listener: tlsListener,
|
||||
}
|
||||
|
||||
if err := httpsServer.Serve(wrappedListener); err != nil && err != http.ErrServerClosed {
|
||||
if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("HTTPS server error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// matchesDomain checks if a certificate domain (which may be a wildcard) matches a server name
|
||||
func matchesDomain(certDomain, serverName string) bool {
|
||||
if certDomain == serverName {
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle wildcard certificates (only at the beginning of a label)
|
||||
if strings.HasPrefix(certDomain, "*.") {
|
||||
certBase := certDomain[2:] // Remove "*."
|
||||
|
||||
// For *.api.bose.io to match events.api.bose.io but not test.content.api.bose.io
|
||||
// We need to ensure only one label is replaced by the wildcard
|
||||
if strings.HasSuffix(serverName, "."+certBase) {
|
||||
// Count dots to ensure we're not matching too many levels
|
||||
serverPrefix := strings.TrimSuffix(serverName, "."+certBase)
|
||||
if !strings.Contains(serverPrefix, ".") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Also match the base domain (e.g., api.bose.io matches *.api.bose.io)
|
||||
if serverName == certBase {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// loggingTLSListener wraps a TLS listener to log connection attempts and handshake failures
|
||||
type loggingTLSListener struct {
|
||||
net.Listener
|
||||
}
|
||||
|
||||
func (l *loggingTLSListener) Accept() (net.Conn, error) {
|
||||
conn, err := l.Listener.Accept()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Wrap the connection to log TLS handshake results
|
||||
return &loggingTLSConn{
|
||||
Conn: conn,
|
||||
addr: conn.RemoteAddr(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// loggingTLSConn wraps a TLS connection to log handshake failures
|
||||
type loggingTLSConn struct {
|
||||
net.Conn
|
||||
addr net.Addr
|
||||
handshakeLogged bool
|
||||
}
|
||||
|
||||
func (c *loggingTLSConn) Read(b []byte) (n int, err error) {
|
||||
n, err = c.Conn.Read(b)
|
||||
|
||||
// Log TLS handshake failures on first read attempt
|
||||
if !c.handshakeLogged {
|
||||
c.handshakeLogged = true
|
||||
|
||||
if err != nil {
|
||||
// Check if this looks like a TLS handshake failure
|
||||
if strings.Contains(err.Error(), "tls:") ||
|
||||
strings.Contains(err.Error(), "handshake") ||
|
||||
strings.Contains(err.Error(), "certificate") {
|
||||
log.Printf("[TLS] ❌ Handshake failed from %s: %v", c.addr, err)
|
||||
}
|
||||
} else if n > 0 {
|
||||
log.Printf("[TLS] ✅ Successful connection from %s", c.addr)
|
||||
}
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ Welcome to the documentation for the Bose SoundTouch Toolkit. This toolkit helps
|
||||
- [HTTPS Setup](guides/HTTPS-SETUP.md)
|
||||
- [Deployment Guide](guides/DEPLOYMENT.md)
|
||||
- [Raspberry Pi Setup](guides/RASPBERRY-PI.md)
|
||||
- [MAC Address Mapping](guides/MAC-ADDRESS-MAPPING.md)
|
||||
- [Troubleshooting](guides/TROUBLESHOOTING.md)
|
||||
|
||||
### Technical Reference
|
||||
|
||||
@@ -119,7 +119,7 @@ soundtouch-service
|
||||
```go
|
||||
// Build custom applications on top of local services
|
||||
client := &http.Client{}
|
||||
resp, _ := client.Get("http://localhost:8000/setup/devices")
|
||||
resp, _ := client.Get("http://localhost:8000/devices")
|
||||
```
|
||||
|
||||
### Privacy-Conscious Users
|
||||
|
||||
+1
-5
@@ -9,7 +9,6 @@
|
||||
* [Getting Started](guides/GETTING-STARTED.md)
|
||||
* [SoundTouch Service](guides/SOUNDTOUCH-SERVICE.md)
|
||||
* [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md)
|
||||
* [MAC Address Mapping](guides/MAC-ADDRESS-MAPPING.md)
|
||||
* [HTTPS Setup](guides/HTTPS-SETUP.md)
|
||||
* [Deployment](guides/DEPLOYMENT.md)
|
||||
* [Raspberry Pi Guide](guides/RASPBERRY-PI.md)
|
||||
@@ -39,16 +38,13 @@
|
||||
* [Key Controls](reference/KEY-CONTROLS.md)
|
||||
* [Feature Mapping](reference/FEATURE-MAPPING.md)
|
||||
|
||||
## Concepts
|
||||
* [Spotify Priming Strategy](concepts/spotify-priming-strategy.md)
|
||||
* [Spotify OAuth](concepts/spotify-oauth.md)
|
||||
|
||||
## Analysis & Research
|
||||
* [API Coverage Analysis](analysis/API-COVERAGE.md)
|
||||
* [Supported URLs](analysis/SUPPORTED-URLS.md)
|
||||
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
|
||||
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
|
||||
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
|
||||
* [Stockholm App Analysis](analysis/stockholm-app-analysis.md)
|
||||
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
|
||||
|
||||
## Appendix (Other Documents)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
### Stockholm App Analysis Report
|
||||
|
||||
#### 1. Overview
|
||||
The Stockholm app is a CEPE MAUI SoundTouch Controller HTML5/JS UI. It is designed to run as a web-based interface for Bose SoundTouch devices, likely served by the device itself or an associated controller.
|
||||
|
||||
- **Technology Stack**: HTML5, CSS3, JavaScript (Minified).
|
||||
- **Key Libraries**:
|
||||
- **jQuery**: Core DOM manipulation and event handling.
|
||||
- **iScroll**: Used for smooth scrolling in lists and carousels.
|
||||
- **Forge**: Used for cryptographic operations (likely for secure communication or authentication).
|
||||
- **WebSocket Polyfill**: Ensures WebSocket compatibility across environments.
|
||||
|
||||
#### 2. Directory Structure
|
||||
- `js/`: Core application logic.
|
||||
- `app/`: Main application entry point (`app.js`).
|
||||
- `models/`: Data models for UI components (Presets, Favorites, Onboarding, etc.).
|
||||
- `music_services/`: Implementation of various music services (Amazon, Deezer, Spotify, BMX, etc.).
|
||||
- `views/`: UI view templates and logic.
|
||||
- `utils/`: Utility functions for security, data analytics, and general-purpose tasks.
|
||||
- `json/`: Configuration files and static data.
|
||||
- `config.json`: Core application configuration including Base64 encoded Bose API endpoints (e.g., streaming, events, BMX registry).
|
||||
- `sourceFeatures.json`: Capability mapping for different sources.
|
||||
- `setup/`: Onboarding and initial device setup logic.
|
||||
- `lang/`: Localization files for multi-language support.
|
||||
|
||||
#### 3. Communication Architecture
|
||||
The app uses several communication channels to interact with the SoundTouch ecosystem:
|
||||
|
||||
- **Socket Communication (`socket_comm.js`)**: Real-time updates and low-latency commands via WebSockets.
|
||||
- **BMX (`bmx.js` & `js/music_services/bmx/`)**: Interactions with the Bose Music eXperience services. Handles account management, navigation, and API response validation.
|
||||
- **Marge (`marge_comm.js`)**: Likely used for interaction with the Marge service (Bose's legacy cloud/proxy service).
|
||||
- **Worker-based Architecture**: Many services use Web Workers (`bmx_worker.js`, `spotify_worker.js`) to handle API requests and data processing in the background, keeping the UI responsive.
|
||||
|
||||
#### 4. Key Features & Functionality
|
||||
- **Multi-Device Management**: Discovering and controlling multiple speakers on the network.
|
||||
- **Music Service Integration**: Deep integration with Spotify, Amazon Music, Deezer, and Pandora.
|
||||
- **Preset Management**: Browsing and setting presets directly from the UI.
|
||||
- **Zone Control**: Creating and managing multi-room groups (Master/Slave configurations).
|
||||
- **Onboarding**: A dedicated setup flow for new devices.
|
||||
- **Analytics & Data Collection**: Modules like `data_analytics.js` and `dc_server.js` suggest tracking of user interactions.
|
||||
|
||||
#### 5. Integration Opportunities for Bose-SoundTouch Project
|
||||
Based on the Stockholm app's capabilities, the following features could be enhanced or added to our Go-based `soundtouch-service`:
|
||||
|
||||
1. **Enhanced BMX Emulation**: Use insights from `bmx_client.js` and `bmx_navigate_response_generator.js` to improve our local BMX implementation.
|
||||
2. **Spotify/Amazon Service Proxies**: Implement the backend logic required to support the same API calls the Stockholm app makes to these services.
|
||||
3. **UI parity**: The Stockholm app's view templates (`views/`) can serve as a reference for our Web Management UI.
|
||||
4. **WebSocket Support**: Ensure our service provides a robust WebSocket interface similar to what the Stockholm app expects for real-time state synchronization.
|
||||
5. **Capability Discovery**: Better utilization of the `sourceFeatures.json` logic to dynamically show/hide features based on the device model and firmware version.
|
||||
|
||||
#### 6. Conclusion
|
||||
The Stockholm app is a mature, full-featured controller that relies heavily on Bose's proprietary BMX and Marge services. By analyzing its client-side logic, we can better understand the expected API responses and interaction patterns needed to provide a seamless local replacement for the Bose Cloud.
|
||||
@@ -1,137 +0,0 @@
|
||||
# Spotify OAuth Integration
|
||||
|
||||
The SoundTouch service supports Spotify OAuth integration to broker access tokens for SoundTouch speakers. This is particularly useful for maintaining Spotify Connect functionality after the Bose cloud shutdown (scheduled for May 2026).
|
||||
|
||||
## OAuth Flows
|
||||
|
||||
The service supports two primary OAuth flows: a browser-based flow and a mobile app-based flow (specifically for the [ueberboese](https://github.com/julius-d/ueberboese-app) app).
|
||||
|
||||
### 1. Browser-based Flow
|
||||
|
||||
The user initiates the flow, completes authorization in their browser, and is redirected back to the service.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as Client (curl/app)
|
||||
participant Service as Service
|
||||
participant Spotify as Spotify Auth Server
|
||||
participant Browser as User's Browser
|
||||
|
||||
Client->>Service: POST /mgmt/spotify/init [Basic Auth]
|
||||
Service-->>Client: {"redirectUrl": "https://accounts.spotify.com/authorize?..."}
|
||||
|
||||
Client->>Browser: User opens URL
|
||||
Browser->>Spotify: User logs in & grants access
|
||||
Spotify-->>Browser: Redirect to /mgmt/spotify/callback?code=abc
|
||||
|
||||
Browser->>Service: GET /mgmt/spotify/callback?code=abc
|
||||
Note over Service: No auth needed for callback
|
||||
|
||||
Service->>Spotify: POST /api/token (exchange code)
|
||||
Spotify-->>Service: {access_token, refresh_token}
|
||||
|
||||
Service->>Spotify: GET /v1/me (fetch profile)
|
||||
Spotify-->>Service: {id, display_name, email}
|
||||
|
||||
Note over Service: Store account to disk
|
||||
|
||||
Service-->>Browser: HTML: "Spotify Connected. You can close this window."
|
||||
```
|
||||
|
||||
### 2. Mobile App Flow (ueberboese)
|
||||
|
||||
The mobile app handles the redirect via a deep link and then confirms the authorization with the service.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as ueberboese Flutter App
|
||||
participant Service as Service
|
||||
participant Spotify as Spotify Auth Server
|
||||
|
||||
App->>Service: POST /mgmt/spotify/init [Basic Auth]
|
||||
Service-->>App: {"redirectUrl": "https://..."}
|
||||
|
||||
App->>Spotify: Open in-app browser (User authorizes)
|
||||
Spotify-->>App: Deep link redirect: ueberboese-login://spotify?code=abc
|
||||
|
||||
App->>Service: POST /mgmt/spotify/confirm?code=abc [Basic Auth]
|
||||
|
||||
Service->>Spotify: POST /api/token (exchange code)
|
||||
Spotify-->>Service: {access_token, refresh_token}
|
||||
|
||||
Service->>Spotify: GET /v1/me (fetch profile)
|
||||
Spotify-->>Service: {profile}
|
||||
|
||||
Service-->>App: {"ok": true}
|
||||
```
|
||||
|
||||
### 3. Token Retrieval (Boot Primer / Speaker Setup)
|
||||
|
||||
Once an account is linked, access tokens can be retrieved for use with speakers (e.g., via the `addUser` ZeroConf command).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Primer as Boot Primer Script
|
||||
participant Service as Service
|
||||
participant Spotify as Spotify Token API
|
||||
participant Speaker as Speaker (Bose ST 20)
|
||||
|
||||
Primer->>Service: GET /mgmt/spotify/token [Basic Auth]
|
||||
|
||||
alt Token expired
|
||||
Service->>Spotify: POST /api/token (refresh)
|
||||
Spotify-->>Service: new tokens
|
||||
end
|
||||
|
||||
Service-->>Primer: {"access_token": "...", "username": "..."}
|
||||
|
||||
Note over Primer: Spotify Connect ZeroConf
|
||||
Primer->>Speaker: POST /SpotifyConnect (addUser with token)
|
||||
Speaker-->>Primer: OK
|
||||
Note over Speaker: Speaker now has Spotify access
|
||||
```
|
||||
|
||||
## Boot Primer Script
|
||||
|
||||
A boot primer script that uses these endpoints to feed Spotify tokens to speakers via ZeroConf is available in the `scripts/spotify/` directory: [spotify-boot-primer.sh](../../scripts/spotify/spotify-boot-primer.sh).
|
||||
|
||||
This script can be installed on the speaker itself (which runs embedded Linux) to automatically prime Spotify Connect at boot time. See [README.md](../../scripts/spotify/README.md) and [INSTALL.md](../../scripts/spotify/INSTALL.md) for instructions.
|
||||
|
||||
### Automated Installation via Service
|
||||
|
||||
The SoundTouch service provides a dedicated management endpoint to automatically handle the installation of the Spotify boot primer on the speaker:
|
||||
`POST /mgmt/devices/{deviceId}/spotify/install-primer`
|
||||
|
||||
### Automated Installation Steps
|
||||
When you run the Spotify primer installation, the service performs the following:
|
||||
1. **Directories**: Creates `/mnt/nv/bin` and `/mnt/nv/BoseApp-Persistence/1` on the speaker.
|
||||
2. **Binary**: Uploads the `spotify-boot-primer` script to the speaker.
|
||||
3. **Configuration**: Automatically generates and uploads `spotify-primer.conf` containing the service's URL and management credentials.
|
||||
4. **Boot Hook**: Injects a call to the primer in the speaker's `/mnt/nv/rc.local` using idempotent markers.
|
||||
5. **Environment**: Updates `/mnt/nv/.profile` to include `/mnt/nv/bin` in the `PATH` for easier manual troubleshooting via SSH.
|
||||
|
||||
- **Idempotent Patching**: The service uses explicit markers to inject the hook, ensuring it doesn't corrupt existing content.
|
||||
- **Coexistence**: The service-injected hook is designed to coexist with a manually installed `rc.local` (e.g., from the community gist). It only adds a call to `/mnt/nv/bin/spotify-boot-primer` if it's not already managed by a service-controlled block.
|
||||
- **Markers**: Look for the following markers in your speaker's `/mnt/nv/rc.local`:
|
||||
- `# --- Aftertouch Spotify hook START ---`
|
||||
- `# --- Aftertouch Spotify hook END ---`
|
||||
- **Cleanup**: Reverting a migration via the service will cleanly remove these marker-delimited blocks.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|---------------------------------------------------|-------|-----------------------------------------------------------------------|
|
||||
| POST | `/mgmt/devices/{deviceId}/spotify/install-primer` | Basic | Install Spotify boot primer on speaker (deviceId or IP) |
|
||||
| GET | `/mgmt/spotify/callback` | None | Browser OAuth callback (redirect from Spotify, returns HTML) |
|
||||
| POST | `/mgmt/spotify/init` | Basic | Start OAuth flow, returns authorization URL |
|
||||
| POST | `/mgmt/spotify/confirm` | Basic | Mobile app confirm (ueberboese deep link delivers code, returns JSON) |
|
||||
| GET | `/mgmt/spotify/accounts` | Basic | List linked Spotify accounts (tokens stripped) |
|
||||
| GET | `/mgmt/spotify/token` | Basic | Get fresh access token (auto-refreshes if expired) |
|
||||
| POST | `/mgmt/spotify/entity` | Basic | Resolve Spotify URI to name + image URL |
|
||||
|
||||
## Security
|
||||
|
||||
- `/mgmt/spotify/callback` is intentionally outside Basic Auth to allow direct redirects from Spotify's authorization server.
|
||||
- All other `/mgmt/*` endpoints require Basic Auth as configured by `--mgmt-username` and `--mgmt-password`.
|
||||
- Tokens are persisted to disk as JSON with restricted file permissions (`0600`).
|
||||
- The `GetAccounts` endpoint strips sensitive tokens from the response.
|
||||
@@ -1,84 +0,0 @@
|
||||
# Spotify Priming Strategy
|
||||
|
||||
This document outlines the strategy for ensuring Bose SoundTouch devices are correctly "primed" for Spotify Connect integration within the AfterTouch ecosystem.
|
||||
|
||||
## Overview
|
||||
|
||||
To enable Spotify Connect for SoundTouch devices, especially for remote availability outside the local network, the speaker must be associated with a Spotify account via a process called "priming." This involves sending an `addUser` command to the speaker's ZeroConf API (port 8200) containing a valid Spotify username and OAuth access token.
|
||||
|
||||
AfterTouch adopts a **Server-Centric Hybrid Model** that prioritizes device cleanliness and user intent while providing automated self-healing.
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. User Intent (Opt-in)
|
||||
AfterTouch replicates the native Bose "Add Source" experience. No Spotify priming occurs until a user explicitly links their Spotify account through the AfterTouch Management Dashboard. This ensures privacy and respects users who do not wish to use Spotify.
|
||||
|
||||
### 2. Device Cleanliness (Minimalist Footprint)
|
||||
We avoid invasive modifications to the speaker's filesystem.
|
||||
- **No On-Device Scripts:** We deprecate the use of internal boot-primer scripts.
|
||||
- **Native Communication:** We rely on the speaker's native ability to talk to Bose services, which are intercepted via DNS to point to the AfterTouch server.
|
||||
|
||||
### 3. Triggers for Priming
|
||||
Priming is triggered when the speaker signals it is active and ready, specifically:
|
||||
|
||||
- **Power On:** When the speaker calls the `/marge/streaming/support/power_on` endpoint, AfterTouch ensures the device's ZeroConf state is correctly primed. This is the primary trigger.
|
||||
- **Manual Override:** Users can manually trigger a "Prime Spotify" from the device list in the UI if needed.
|
||||
|
||||
During any of these events, the server:
|
||||
1. Checks if a Spotify account is linked in AfterTouch.
|
||||
2. Checks the device's current priming status (via ZeroConf).
|
||||
3. If unprimed and an account is linked, it pushes the priming command.
|
||||
|
||||
### 4. Automated Recovery
|
||||
AfterTouch ensures that if a speaker loses its session (due to a crash or power loss), it is re-primed when it next powers on and reaches out to the service.
|
||||
|
||||
### 5. Decoupling
|
||||
The logic for account management and device interaction remains decoupled:
|
||||
- **Spotify Service:** Manages OAuth tokens and account state.
|
||||
- **Discovery Service:** Finds devices and tracks their network presence.
|
||||
- **Orchestrator:** Connects the two, deciding when to push tokens to discovered devices based on the current link status.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Initial Setup (The "Add Source" UX)
|
||||
1. User opens the AfterTouch Dashboard.
|
||||
2. User selects "Link Spotify Account."
|
||||
3. OAuth flow completes; AfterTouch stores the token.
|
||||
4. AfterTouch immediately triggers a discovery run to find and prime all compatible speakers.
|
||||
|
||||
### Maintenance (The "Watchdog" UX)
|
||||
1. A speaker reboots or loses its token.
|
||||
2. A discovery event occurs (periodic or triggered by UI).
|
||||
3. AfterTouch detects the "Empty" user state on the speaker.
|
||||
4. AfterTouch pushes a fresh token from the Spotify Service.
|
||||
5. UI reflects that the device is "Managed by AfterTouch" and healthy.
|
||||
|
||||
### Manual Override
|
||||
Users can manually trigger a "Re-prime" or "Refresh Link" from the device list in the UI if they suspect the automated self-healing is delayed or if they want to force a specific account onto a device.
|
||||
|
||||
## Network Topology & Deployment Scenarios
|
||||
|
||||
The strategy adapts based on where the AfterTouch server is deployed:
|
||||
|
||||
### Local Deployment (Home Server / Docker)
|
||||
- **Mechanism:** Both "Pull" (Marge) and "Push" (ZeroConf side-channel) are used.
|
||||
- **Advantage:** The server can proactively fix the speaker's state via port 8200 as soon as it sees a "Liveness Signal."
|
||||
|
||||
### External Deployment (Cloud VPS)
|
||||
- **Mechanism:** Primarily relies on "Pull" (Marge).
|
||||
- **Constraint:** The server cannot reach port 8200 on the speaker due to NAT/Firewall.
|
||||
- **Strategy:** In this scenario, AfterTouch acts as a passive token provider. The speaker must initiate the connection to our intercepted Bose endpoints to receive its Spotify configuration. If the speaker completely loses its user state and stops "pulling," a manual re-prime from a local machine or a temporary local discovery run might be required.
|
||||
|
||||
## Transition & Cleanup
|
||||
|
||||
As AfterTouch moves to the Server-Centric model, we will:
|
||||
1. **Revert On-Device Migration:** Update the Setup Manager to remove legacy `spotify-boot-primer` scripts and `rc.local` hooks from the speakers.
|
||||
2. **Consolidated Directory:** We maintain the `/mnt/nv/soundtouch-service/` base directory for other configuration needs (e.g., `aftertouch.resolv.conf`), but it will no longer contain Spotify-specific credentials or scripts.
|
||||
3. **No On-Device Credentials:** The `/mnt/nv/soundtouch-service/spotify-primer.conf` will be removed, ensuring that no sensitive AfterTouch login details are stored on the speaker in plain text.
|
||||
|
||||
## Implementation Roadmap (Conceptual)
|
||||
|
||||
1. **Revert On-Device Migration:** Update the Setup Manager to remove legacy scripts and `rc.local` hooks.
|
||||
2. **Server-Side Priming Logic:** Implement a `PrimeDevice(ip)` method in the server that fetches a fresh token and calls the ZeroConf API.
|
||||
3. **Discovery Hook:** Integrate `PrimeDevice` into the discovery handler (`handleDiscoveredDevice`) with a check for unprimed state.
|
||||
4. **UI Enhancements:** Update the Speaker List to show "Spotify Linked" status and provide manual refresh buttons.
|
||||
@@ -33,23 +33,16 @@ The `soundtouch-service` now includes a built-in HTTPS listener. This simplifies
|
||||
|
||||
- **HTTPS Port**: Configurable via `HTTPS_PORT` environment variable (defaults to `8443`).
|
||||
- **HTTPS Server URL**: Configurable via `HTTPS_SERVER_URL` (e.g., `https://mysoundtouch.local:8443`). If not set, the service attempts to guess it using the system hostname.
|
||||
- **Domain Coverage**: Automatically presents a certificate with comprehensive coverage using wildcard certificates (`*.api.bose.io`, `*.api.bosecm.com`) plus specific domains (`streaming.bose.com`, `updates.bose.com`, `stats.bose.com`, `bmx.bose.com`, `worldwide.bose.com`, `bose-prod.apigee.net`, etc.).
|
||||
- **Wildcard Support**: Uses RFC-compliant wildcard certificates for automatic coverage of all API subdomains, including event analytics endpoints like `events.api.bosecm.com`, `eventsdev.api.bosecm.com`, and future API services.
|
||||
- **TLS Error Logging**: Comprehensive logging of TLS handshake attempts, certificate matching, and connection failures for debugging DNS redirection issues.
|
||||
- **Domain Coverage**: Automatically presents a certificate for `streaming.bose.com`, `updates.bose.com`, `stats.bose.com`, `bmx.bose.com`, and `content.api.bose.io`.
|
||||
- **Automatic Setup**: On first start, it generates a server certificate signed by your AfterTouch local Root CA.
|
||||
|
||||
#### TLS Security & Debugging
|
||||
#### TLS Security
|
||||
|
||||
The built-in HTTPS listener is configured to use modern and secure TLS settings while maintaining compatibility with SoundTouch devices (which support up to TLS 1.2 with OpenSSL 1.0.2).
|
||||
|
||||
- **Minimum TLS Version**: TLS 1.2
|
||||
- **Preferred Cipher Suites**:
|
||||
- `ECDHE-RSA-AES128-GCM-SHA256`
|
||||
- **TLS Debugging**: Detailed logging of:
|
||||
- Certificate requests by domain (`[TLS] Certificate request for ServerName: events.api.bosecm.com`)
|
||||
- Wildcard certificate matching (`[TLS] ✅ Serving certificate for events.api.bosecm.com (matched *.api.bosecm.com)`)
|
||||
- Handshake failures (`[TLS] ❌ Handshake failed from 192.168.1.50: tls: certificate not found`)
|
||||
- Successful connections (`[TLS] ✅ Successful connection from 192.168.1.50`)
|
||||
- `ECDHE-RSA-AES256-GCM-SHA384`
|
||||
- `ECDHE-RSA-CHACHA20-POLY1305`
|
||||
- `RSA-AES128-GCM-SHA256` (Legacy support)
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
# MAC Address to Serial Number Mapping
|
||||
|
||||
**Understanding and troubleshooting device identification in SoundTouch service**
|
||||
|
||||
This guide explains how the SoundTouch service handles device identification through MAC address to serial number mapping, and how to troubleshoot related issues.
|
||||
|
||||
## 📋 **Overview**
|
||||
|
||||
The SoundTouch service uses two different identifiers for devices:
|
||||
|
||||
- **MAC Address** (`A81B6A536A98`) - Used in HTTP API requests and UPnP discovery
|
||||
- **Serial Number** (`I6332527703739342000020`) - Used for internal file storage
|
||||
|
||||
The service automatically maps between these identifiers so that API requests using MAC addresses can access files stored using serial numbers.
|
||||
|
||||
## 🔍 **How It Works**
|
||||
|
||||
### Request Flow
|
||||
```
|
||||
1. HTTP Request: GET /streaming/account/3230304/device/A81B6A536A98/presets
|
||||
2. MAC Resolution: A81B6A536A98 → I6332527703739342000020
|
||||
3. File Access: accounts/3230304/devices/I6332527703739342000020/Presets.xml
|
||||
```
|
||||
|
||||
### UPnP Discovery Integration
|
||||
The service extracts MAC addresses from UPnP device descriptions:
|
||||
|
||||
```xml
|
||||
<!-- From http://192.168.1.100:8091/XD/BO5EBO5E-F00D-F00D-FEED-A81B6A536A98.xml -->
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<device>
|
||||
<friendlyName>Sound Machinery</friendlyName>
|
||||
<modelName>SoundTouch 10</modelName>
|
||||
<serialNumber>A81B6A536A98</serialNumber> <!-- MAC address here -->
|
||||
</device>
|
||||
</root>
|
||||
```
|
||||
|
||||
## ⚙️ **Automatic Setup**
|
||||
|
||||
The mapping is created automatically when the service starts:
|
||||
|
||||
1. **Directory Scan**: Service scans `data/accounts/{account}/devices/{serial}/`
|
||||
2. **DeviceInfo.xml**: Reads MAC address from each device's info file
|
||||
3. **Mapping Creation**: Creates MAC → Serial mapping in memory
|
||||
4. **Normalization**: Handles different MAC address formats automatically
|
||||
|
||||
## 🛠️ **Supported MAC Address Formats**
|
||||
|
||||
The service handles all common MAC address formats automatically:
|
||||
|
||||
| Format | Example | Status |
|
||||
|-------------|---------------------|-------------|
|
||||
| Standard | `A81B6A536A98` | ✅ Supported |
|
||||
| Lowercase | `a81b6a536a98` | ✅ Supported |
|
||||
| With Colons | `A8:1B:6A:53:6A:98` | ✅ Supported |
|
||||
| With Dashes | `A8-1B-6A-53-6A-98` | ✅ Supported |
|
||||
| Mixed Case | `a81B6a536A98` | ✅ Supported |
|
||||
| With Spaces | ` A81B6A536A98 ` | ✅ Supported |
|
||||
|
||||
## 🔧 **Troubleshooting**
|
||||
|
||||
### Problem: API requests fail with "file not found" errors
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
GET /streaming/account/3230304/device/A81B6A536A98/presets
|
||||
→ 500 Internal Server Error
|
||||
→ Log: "open .../devices/A81B6A536A98/Presets.xml: no such file or directory"
|
||||
```
|
||||
|
||||
**Diagnosis:**
|
||||
1. Check if mapping exists:
|
||||
```bash
|
||||
# Look for device directory
|
||||
ls data/accounts/3230304/devices/
|
||||
# Should show serial numbers like: I6332527703739342000020
|
||||
```
|
||||
|
||||
2. Check DeviceInfo.xml:
|
||||
```bash
|
||||
cat data/accounts/3230304/devices/I6332527703739342000020/DeviceInfo.xml
|
||||
# Look for <macAddress> field
|
||||
```
|
||||
|
||||
**Solutions:**
|
||||
|
||||
#### Solution 1: Restart the Service
|
||||
The mapping is created at startup. Simply restart:
|
||||
```bash
|
||||
sudo systemctl restart soundtouch-service
|
||||
```
|
||||
|
||||
#### Solution 2: Check DeviceInfo.xml Format
|
||||
Ensure the MAC address is present:
|
||||
```xml
|
||||
<info deviceID="I6332527703739342000020">
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>A81B6A536A98</macAddress> <!-- Must be present -->
|
||||
<ipAddress>192.168.178.35</ipAddress>
|
||||
</networkInfo>
|
||||
</info>
|
||||
```
|
||||
|
||||
#### Solution 3: Manual Device Addition
|
||||
If the device was added manually, ensure proper structure:
|
||||
```bash
|
||||
# Create device directory using serial number
|
||||
mkdir -p data/accounts/3230304/devices/I6332527703739342000020
|
||||
|
||||
# Create DeviceInfo.xml with MAC address
|
||||
cat > data/accounts/3230304/devices/I6332527703739342000020/DeviceInfo.xml << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="I6332527703739342000020">
|
||||
<name>My SoundTouch Device</name>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>A81B6A536A98</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
</info>
|
||||
EOF
|
||||
```
|
||||
|
||||
### Problem: UPnP discovery not creating mappings
|
||||
|
||||
**Check UPnP accessibility:**
|
||||
```bash
|
||||
# Test UPnP endpoint directly
|
||||
curl http://192.168.1.100:8091/XD/BO5EBO5E-F00D-F00D-FEED-A81B6A536A98.xml
|
||||
|
||||
# Should return XML with <serialNumber> field
|
||||
```
|
||||
|
||||
**Enable debug logging:**
|
||||
```bash
|
||||
# Check service logs for UPnP activity
|
||||
journalctl -u soundtouch-service -f | grep UPnP
|
||||
```
|
||||
|
||||
### Problem: Case or format mismatches
|
||||
|
||||
This should be handled automatically, but you can verify:
|
||||
|
||||
**Test different formats:**
|
||||
```bash
|
||||
# All of these should work the same:
|
||||
curl http://localhost:8000/streaming/account/3230304/device/A81B6A536A98/presets
|
||||
curl http://localhost:8000/streaming/account/3230304/device/a81b6a536a98/presets
|
||||
curl http://localhost:8000/streaming/account/3230304/device/A8:1B:6A:53:6A:98/presets
|
||||
```
|
||||
|
||||
## 📊 **Monitoring and Diagnostics**
|
||||
|
||||
### Check Current Mappings
|
||||
The service logs mapping creation at startup:
|
||||
```bash
|
||||
journalctl -u soundtouch-service | grep "MAC.*serial"
|
||||
```
|
||||
|
||||
### Verify File Structure
|
||||
Ensure proper directory organization:
|
||||
```
|
||||
data/
|
||||
└── accounts/
|
||||
└── 3230304/
|
||||
└── devices/
|
||||
└── I6332527703739342000020/ # Serial number directory
|
||||
├── DeviceInfo.xml # Contains MAC address
|
||||
├── Presets.xml
|
||||
└── Sources.xml
|
||||
```
|
||||
|
||||
## 🔗 **Related Documentation**
|
||||
|
||||
- [Device Initial Setup](DEVICE-INITIAL-SETUP.md) - Setting up new devices
|
||||
- [Troubleshooting Guide](TROUBLESHOOTING.md) - General troubleshooting steps
|
||||
- [SoundTouch Service](SOUNDTOUCH-SERVICE.md) - Service configuration and management
|
||||
|
||||
## 🏗️ **Technical Implementation**
|
||||
|
||||
For developers interested in the technical details:
|
||||
|
||||
### Normalization Algorithm
|
||||
```go
|
||||
// MAC addresses are normalized by:
|
||||
// 1. Removing spaces, colons, and dashes
|
||||
// 2. Converting to uppercase
|
||||
// Examples:
|
||||
// "a8:1b:6a:53:6a:98" → "A81B6A536A98"
|
||||
// "A8-1B-6A-53-6A-98" → "A81B6A536A98"
|
||||
```
|
||||
|
||||
### Lookup Process
|
||||
```go
|
||||
// 1. Try exact match first
|
||||
// 2. If not found, try normalized version
|
||||
// 3. Return serial number for file access
|
||||
```
|
||||
|
||||
### Performance
|
||||
- **Lookup Time**: O(1) - Hash map lookup
|
||||
- **Memory Usage**: ~40 bytes per device mapping
|
||||
- **Initialization**: Scans all devices once at startup
|
||||
|
||||
## 📝 **Best Practices**
|
||||
|
||||
1. **Use Discovery**: Let UPnP discovery create mappings automatically
|
||||
2. **Consistent Format**: Store MAC addresses consistently in DeviceInfo.xml
|
||||
3. **Service Restart**: Restart service after manual device additions
|
||||
4. **Monitoring**: Check logs for mapping creation during startup
|
||||
5. **Backup**: Keep DeviceInfo.xml files backed up
|
||||
|
||||
## ⚠️ **Known Limitations**
|
||||
|
||||
- Mappings are created only at service startup
|
||||
- Manual device additions require service restart
|
||||
- MAC addresses must be present in DeviceInfo.xml
|
||||
- No automatic cleanup of stale mappings (restart required)
|
||||
@@ -13,8 +13,6 @@ The service provides:
|
||||
- **🌐 Web Management UI**: Browser-based interface for device management
|
||||
- **💾 Persistent Data**: Store device configurations, presets, and usage statistics
|
||||
- **📝 HTTP Recording**: Persist all interactions as re-playable `.http` files
|
||||
- **🔄 Endpoint Mirroring**: Asynchronously mirror local requests to Bose cloud for parity testing
|
||||
- **⚖️ Parity Logging**: Detect and record discrepancies between local and official Bose responses
|
||||
- **📥 Session Archiving**: Download entire interaction sessions as `.tar.gz` for offline analysis
|
||||
- **🔍 Auto-Discovery**: Automatically detect and configure SoundTouch devices
|
||||
- **🔒 Offline Operation**: Continue using full device functionality without internet
|
||||
@@ -169,9 +167,6 @@ The service supports multiple ways to configure its behavior. When multiple sour
|
||||
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
|
||||
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for `resolv.conf` migration) | `:53` |
|
||||
| `MIRROR_ENABLED` | | Enable background mirroring of specific endpoints to Bose cloud | `false` |
|
||||
| `MIRROR_ENDPOINTS` | | Comma-separated list of path patterns to mirror (e.g., `/streaming/account/*/device/*/recent`) | `[]` |
|
||||
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
|
||||
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
|
||||
|
||||
### Configuration Examples
|
||||
@@ -212,23 +207,23 @@ Device migration switches your SoundTouch devices from Bose's cloud services to
|
||||
|
||||
```bash
|
||||
# Get migration summary first
|
||||
curl http://localhost:8000/setup/migration-summary/192.168.1.100
|
||||
curl http://localhost:8000/setup/devices/192.168.1.100/summary
|
||||
|
||||
# Perform migration
|
||||
curl -X POST http://localhost:8000/setup/migrate/192.168.1.100
|
||||
curl -X POST http://localhost:8000/setup/devices/192.168.1.100/migrate
|
||||
|
||||
# Verify migration status
|
||||
curl http://localhost:8000/setup/devices
|
||||
curl http://localhost:8000/devices
|
||||
```
|
||||
|
||||
#### Advanced Migration Options
|
||||
|
||||
```bash
|
||||
# Migration with proxy fallback for original services
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?proxy_url=http://localhost:8000&marge=original&stats=original"
|
||||
curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?proxy_url=http://localhost:8000&marge=original&stats=original"
|
||||
|
||||
# Migration with custom target URL
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?target_url=https://my-server.com:8000"
|
||||
curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?target_url=https://my-server.com:8000"
|
||||
```
|
||||
|
||||
### Post-Migration Verification
|
||||
@@ -237,13 +232,13 @@ After migration, verify the device is working correctly:
|
||||
|
||||
```bash
|
||||
# Check device status
|
||||
curl http://localhost:8000/setup/devices
|
||||
curl http://localhost:8000/devices
|
||||
|
||||
# Test preset functionality
|
||||
curl "http://192.168.1.100:8090/presets"
|
||||
|
||||
# Monitor device events (if needed)
|
||||
curl "http://localhost:8000/events/192.168.1.100"
|
||||
curl "http://localhost:8000/devices/08DF1F0BA325/events"
|
||||
```
|
||||
|
||||
#### ResolvConf Migration (DHCP-Aware DNS Redirection)
|
||||
@@ -315,39 +310,11 @@ You can enable and configure the DNS server via the Web UI or environment variab
|
||||
#### Manual Discovery via DNS
|
||||
Even without migrating a device, you can use the DNS server to discover what a device is querying by manually setting your router's DNS or the device's DNS to point to the AfterTouch service.
|
||||
|
||||
## Endpoint Mirroring & Parity Logging
|
||||
|
||||
The SoundTouch service includes a powerful **Mirroring** feature that allows you to handle requests locally while simultaneously forwarding them to the official Bose cloud in the background. This is primarily used for maintaining long-term compatibility and verifying the accuracy of the local emulation.
|
||||
|
||||
### How Mirroring Works
|
||||
|
||||
When an endpoint is configured for mirroring:
|
||||
1. **GET Requests**: Handled locally first (Primary). The response is returned to the speaker immediately. In the background, the same request is sent to Bose.
|
||||
2. **POST/PUT/DELETE Requests**: Handled locally first. The service then synchronously (but without blocking the speaker's response) forwards the request to Bose to ensure the "official" account state stays in sync with your local changes (e.g., updating a preset).
|
||||
|
||||
### Parity Logging
|
||||
|
||||
The **Parity Logger** automatically compares the response from your local service with the one received from Bose. If it detects any discrepancies, it:
|
||||
1. Logs a warning to the console: `[PARITY] Mismatch detected for GET /...`
|
||||
2. Saves a detailed JSON report to `data/parity_mismatches/`.
|
||||
|
||||
Each report includes the full request, both response bodies, and a summary of what differed (status codes, content types, or missing/different XML tags).
|
||||
|
||||
### Configuration
|
||||
|
||||
Mirroring is configured via the **Settings** tab in the Web UI or through global settings:
|
||||
- **Mirror Enabled**: Master switch for the mirroring infrastructure.
|
||||
- **Mirror Endpoints**: A list of URL path patterns to mirror. You can use wildcards (`*`) to match variable parts like account or device IDs.
|
||||
- Example: `/streaming/account/*/device/*/recent`
|
||||
- Example: `/accounts/*/devices/*/presets/*`
|
||||
|
||||
Mirrored requests are also recorded in the **Interaction Log** under the category `upstream-mirror`, allowing you to see side-by-side exactly how our service's behavior compares to the official one.
|
||||
|
||||
## API Reference
|
||||
|
||||
### Discovery & Setup
|
||||
|
||||
#### `GET /setup/devices`
|
||||
#### `GET /devices`
|
||||
Lists all discovered SoundTouch devices with their current status.
|
||||
|
||||
**Response:**
|
||||
@@ -368,10 +335,10 @@ Lists all discovered SoundTouch devices with their current status.
|
||||
#### `POST /setup/discover`
|
||||
Triggers immediate network device discovery.
|
||||
|
||||
#### `GET /setup/info/{deviceIP}`
|
||||
#### `GET /devices/{deviceIP}/info`
|
||||
Gets detailed device information and configuration.
|
||||
|
||||
#### `GET /setup/migration-summary/{deviceIP}`
|
||||
#### `GET /setup/devices/{deviceIP}/summary`
|
||||
Analyzes device configuration and provides migration preview.
|
||||
|
||||
**Response:**
|
||||
@@ -388,7 +355,7 @@ Analyzes device configuration and provides migration preview.
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /setup/migrate/{deviceIP}`
|
||||
#### `POST /setup/devices/{deviceIP}/migrate`
|
||||
Migrates device to use local services.
|
||||
|
||||
**Query Parameters:**
|
||||
@@ -399,6 +366,33 @@ Migrates device to use local services.
|
||||
- `sw_update`: Set to "original" to proxy update requests (optional)
|
||||
- `bmx`: Set to "original" to proxy BMX requests (optional)
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/revert`
|
||||
Reverts device to Bose cloud defaults.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/trust-ca`
|
||||
Injects the AfterTouch root CA into the device's trust store.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/sync`
|
||||
Syncs presets and recents from the device to local storage.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/backup`
|
||||
Creates a backup of the current device configuration.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/ensure-remote-services`
|
||||
Enables persistent SSH/remote services on the device.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/remove-remote-services`
|
||||
Removes persistent SSH/remote services from the device.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/test-connection`
|
||||
Tests HTTPS connection from device to service.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/test-hosts`
|
||||
Tests /etc/hosts redirection on the device.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/test-dns`
|
||||
Tests DNS redirection on the device.
|
||||
|
||||
### BMX Services (Bose Media eXchange)
|
||||
|
||||
#### `GET /bmx/registry/v1/services`
|
||||
@@ -503,17 +497,6 @@ The web management interface provides a comprehensive dashboard for managing you
|
||||
|
||||
The service automatically records all HTTP interactions (both those handled locally and those proxied upstream) as `.http` files. These files are compatible with the [IntelliJ IDEA HTTP Client](https://www.jetbrains.com/help/idea/exploring-http-syntax.html).
|
||||
|
||||
### Internal Paths (Excluding Traffic)
|
||||
|
||||
To prevent internal management traffic (like the Web UI or setup API calls) from cluttering your interaction logs, you can configure **Internal Paths**. Requests matching these patterns will be processed normally but will **not** be recorded by the `RecordMiddleware`.
|
||||
|
||||
By default, we recommend adding:
|
||||
- `/setup/*`: Management API calls
|
||||
- `/web/*`: Static Web UI resources
|
||||
- `/media/*`: Icons and static media
|
||||
|
||||
You can configure these via the **Settings** tab in the Web UI or using the `--internal-paths` flag.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Session Grouping**: All interactions from a single server session are stored in a dedicated directory named `{timestamp}-{pid}`.
|
||||
@@ -658,13 +641,13 @@ find data/stats/ -name "*.json" -mtime +90 -delete
|
||||
- **Description**: Browser-based guided flow for discovery, data sync, and migration.
|
||||
|
||||
### Setup API
|
||||
- `GET /setup/devices`: List all known (auto-discovered and manual) devices.
|
||||
- `POST /setup/devices`: Manually add a device by IP.
|
||||
- `GET /devices`: List all known (auto-discovered and manual) devices.
|
||||
- `POST /devices`: Manually add a device by IP.
|
||||
- `POST /setup/discover`: Trigger a new network discovery scan.
|
||||
- `GET /setup/discovery-status`: Check if a scan is currently in progress.
|
||||
- `POST /setup/sync/{deviceIP}`: Fetch presets, recents, and sources from a device.
|
||||
- `GET /setup/summary/{deviceIP}`: Get a detailed migration readiness summary.
|
||||
- `POST /setup/migrate/{deviceIP}`: Migrate a device using the specified method (XML/Hosts).
|
||||
- `POST /devices/{deviceIP}/sync`: Fetch presets, recents, and sources from a device.
|
||||
- `GET /devices/{deviceIP}/summary`: Get a detailed migration readiness summary.
|
||||
- `POST /devices/{deviceIP}/migrate`: Migrate a device using the specified method (XML/Hosts).
|
||||
- `GET /setup/ca.crt`: Download the Root CA certificate for manual installation.
|
||||
|
||||
#### `GET /setup/interactions`
|
||||
@@ -811,7 +794,7 @@ soundtouch:
|
||||
name: "Living Room Speaker"
|
||||
|
||||
rest:
|
||||
- resource: "http://localhost:8000/setup/devices"
|
||||
- resource: "http://localhost:8000/devices"
|
||||
scan_interval: 60
|
||||
sensor:
|
||||
- name: "SoundTouch Devices"
|
||||
|
||||
@@ -43,7 +43,7 @@ To migrate your speakers, the service needs SSH access. You can enable it by:
|
||||
3. Rebooting the speaker (unplug/replug).
|
||||
|
||||
**Verify SSH Access:**
|
||||
- Confirm the device responds to SSH without a password: `ssh -o HostKeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa root@<IP>`
|
||||
- Confirm the device responds to SSH without a password: `ssh -oHostKeyAlgorithms=+ssh-rsa root@<IP>`
|
||||
- Or use the **Migration** tab in the Web UI to see if the device shows a "✅ Success" status for SSH.
|
||||
Once enabled, you can log in as `root` (no password).
|
||||
|
||||
|
||||
@@ -817,42 +817,6 @@ Use this checklist to systematically troubleshoot issues:
|
||||
|
||||
---
|
||||
|
||||
## 🆔 **Device Identification & Mapping Issues**
|
||||
|
||||
### ❌ "File not found" errors with MAC addresses
|
||||
|
||||
**Symptoms:**
|
||||
```
|
||||
GET /streaming/account/3230304/device/A81B6A536A98/presets
|
||||
→ 500 Internal Server Error
|
||||
→ Log: "open .../devices/A81B6A536A98/Presets.xml: no such file or directory"
|
||||
```
|
||||
|
||||
**Cause:** The service uses MAC addresses in API requests but stores files using device serial numbers. A mapping system resolves MAC addresses to serial numbers automatically.
|
||||
|
||||
**Quick Solutions:**
|
||||
|
||||
1. **Restart the service** (mappings are created at startup):
|
||||
```bash
|
||||
sudo systemctl restart soundtouch-service
|
||||
```
|
||||
|
||||
2. **Check device directory structure**:
|
||||
```bash
|
||||
# Files should be stored by serial number, not MAC
|
||||
ls data/accounts/3230304/devices/
|
||||
# Should show: I6332527703739342000020/ (not A81B6A536A98/)
|
||||
```
|
||||
|
||||
3. **Verify DeviceInfo.xml contains MAC address**:
|
||||
```bash
|
||||
cat data/accounts/3230304/devices/*/DeviceInfo.xml | grep macAddress
|
||||
```
|
||||
|
||||
**For detailed diagnosis and solutions**, see: [**MAC Address Mapping Guide**](MAC-ADDRESS-MAPPING.md)
|
||||
|
||||
---
|
||||
|
||||
## 🛟 **Getting More Help**
|
||||
|
||||
### Information to Gather
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
// Package main demonstrates the new recording filename format that includes date information.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== Recording Filename Format Demo ===")
|
||||
fmt.Println()
|
||||
|
||||
// Create a temporary directory for the demo
|
||||
tmpDir, err := os.MkdirTemp("", "recording-filename-demo")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if removeErr := os.RemoveAll(tmpDir); removeErr != nil {
|
||||
log.Printf("Failed to remove temp directory: %v", removeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
fmt.Printf("Demo recordings will be saved to: %s\n\n", tmpDir)
|
||||
|
||||
// Create a recorder with async disabled for predictable demo output
|
||||
if envErr := os.Setenv("RECORDER_ASYNC", "false"); envErr != nil {
|
||||
log.Printf("Failed to set environment variable: %v", envErr)
|
||||
}
|
||||
|
||||
recorder := proxy.NewRecorder(tmpDir)
|
||||
defer recorder.Close()
|
||||
|
||||
fmt.Printf("Recorder session ID: %s\n", recorder.SessionID)
|
||||
fmt.Println()
|
||||
|
||||
// Create some sample HTTP requests to record
|
||||
requests := []struct {
|
||||
method string
|
||||
path string
|
||||
category string
|
||||
}{
|
||||
{"GET", "/info", "self"},
|
||||
{"POST", "/volume", "self"},
|
||||
{"GET", "/nowPlaying", "self"},
|
||||
{"PUT", "/preset_1", "self"},
|
||||
}
|
||||
|
||||
fmt.Println("Recording sample HTTP interactions...")
|
||||
fmt.Println()
|
||||
|
||||
for i, req := range requests {
|
||||
// Create a mock HTTP request
|
||||
httpReq, reqErr := http.NewRequest(req.method, "http://soundtouch.local:8090"+req.path, nil)
|
||||
if reqErr != nil {
|
||||
log.Printf("Failed to create request: %v", reqErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create a mock response
|
||||
httpRes := &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Request: httpReq,
|
||||
}
|
||||
httpRes.Header.Set("Content-Type", "application/xml")
|
||||
|
||||
// Record the interaction
|
||||
err = recorder.Record(req.category, httpReq, httpRes)
|
||||
if err != nil {
|
||||
log.Printf("Failed to record interaction: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("%d. Recorded: %s %s\n", i+1, req.method, req.path)
|
||||
|
||||
// Small delay to show different timestamps
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("=== Generated Filenames ===")
|
||||
fmt.Println()
|
||||
|
||||
// Walk through the recordings directory to show the generated filenames
|
||||
interactionsDir := filepath.Join(tmpDir, "interactions")
|
||||
|
||||
err = filepath.Walk(interactionsDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.HasSuffix(info.Name(), ".http") {
|
||||
// Get relative path from interactions directory
|
||||
rel, _ := filepath.Rel(interactionsDir, path)
|
||||
fmt.Printf("📁 %s\n", rel)
|
||||
|
||||
// Parse and explain the filename format
|
||||
filename := info.Name()
|
||||
parts := strings.Split(strings.TrimSuffix(filename, ".http"), "-")
|
||||
|
||||
if len(parts) == 4 && len(parts[1]) == 8 {
|
||||
// New format: count-yyyyMMdd-HHMMSS.sss-method.http
|
||||
counter := parts[0]
|
||||
dateStr := parts[1]
|
||||
timeStr := parts[2]
|
||||
method := parts[3]
|
||||
|
||||
// Format for display
|
||||
date := dateStr[0:4] + "-" + dateStr[4:6] + "-" + dateStr[6:8]
|
||||
time := timeStr[0:2] + ":" + timeStr[2:4] + ":" + timeStr[4:]
|
||||
|
||||
fmt.Printf(" 📋 Format: count-yyyyMMdd-HHMMSS.sss-method.http\n")
|
||||
fmt.Printf(" 🔢 Counter: %s\n", counter)
|
||||
fmt.Printf(" 📅 Date: %s (from %s)\n", date, dateStr)
|
||||
fmt.Printf(" 🕒 Time: %s (from %s)\n", time, timeStr)
|
||||
fmt.Printf(" 🔧 Method: %s\n", method)
|
||||
fmt.Printf(" ✨ Full timestamp: %s %s\n", date, time)
|
||||
} else {
|
||||
fmt.Printf(" ⚠️ Legacy format or unexpected structure\n")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Error walking directory: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("=== Comparison with Old Format ===")
|
||||
fmt.Println()
|
||||
fmt.Println("🔴 OLD format (time only): 0047-21-53-06.128-GET.http")
|
||||
fmt.Println(" - No date information in filename")
|
||||
fmt.Println(" - Date extracted from session ID directory")
|
||||
fmt.Println(" - Confusing when recordings span midnight")
|
||||
fmt.Println()
|
||||
fmt.Println("🟢 NEW format (date + time): 0047-20260223-215306.128-GET.http")
|
||||
fmt.Println(" - Complete timestamp in filename")
|
||||
fmt.Println(" - Self-contained, no need to check directory")
|
||||
fmt.Println(" - Clear chronological ordering")
|
||||
fmt.Println()
|
||||
|
||||
fmt.Println("=== Benefits ===")
|
||||
fmt.Println("✅ No confusion when recordings cross midnight")
|
||||
fmt.Println("✅ Complete timestamp visible at a glance")
|
||||
fmt.Println("✅ Better sorting and organization")
|
||||
fmt.Println("✅ Backwards compatible with existing parsing logic")
|
||||
fmt.Println()
|
||||
|
||||
// Test the list interactions functionality
|
||||
fmt.Println("=== Using ListInteractions API ===")
|
||||
fmt.Println()
|
||||
|
||||
interactions, err := recorder.ListInteractions("", "", "")
|
||||
if err != nil {
|
||||
log.Printf("Failed to list interactions: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d recorded interactions:\n", len(interactions))
|
||||
|
||||
for i := range interactions {
|
||||
interaction := &interactions[i]
|
||||
fmt.Printf("%d. %s %s - %s (File: %s)\n",
|
||||
i+1, interaction.Method, interaction.Path,
|
||||
interaction.Timestamp, interaction.ID)
|
||||
}
|
||||
|
||||
fmt.Printf("\nDemo completed! Recordings saved in: %s\n", tmpDir)
|
||||
fmt.Println("You can explore the generated files to see the new format in action.")
|
||||
}
|
||||
@@ -356,6 +356,13 @@ func (ws *WebSocketClient) attemptReconnect(config *WebSocketConfig) {
|
||||
}
|
||||
|
||||
attempt++
|
||||
|
||||
// Check if device is reachable before attempting full WS connection to reduce log noise
|
||||
if err := ws.client.Ping(); err != nil {
|
||||
ws.logger.Printf("Reconnection attempt %d skipped: device unreachable (%v)", attempt, err)
|
||||
continue
|
||||
}
|
||||
|
||||
ws.logger.Printf("Reconnection attempt %d", attempt)
|
||||
|
||||
if err := ws.connectWithConfig(config); err != nil {
|
||||
|
||||
@@ -30,10 +30,6 @@ type Config struct {
|
||||
// Cache settings
|
||||
CacheEnabled bool `env:"CACHE_ENABLED" default:"true"`
|
||||
CacheTTL time.Duration `env:"CACHE_TTL" default:"30s"`
|
||||
|
||||
// Migration settings (TODO: Remove after 3-4 releases when all devices are migrated)
|
||||
MigrationEnabled bool `env:"MIGRATION_ENABLED" default:"true"`
|
||||
MigrationDryRun bool `env:"MIGRATION_DRY_RUN" default:"false"`
|
||||
}
|
||||
|
||||
// DeviceConfig represents a configured SoundTouch device
|
||||
@@ -52,8 +48,6 @@ func DefaultConfig() *Config {
|
||||
PreferredDevices: []DeviceConfig{},
|
||||
HTTPTimeout: 10 * time.Second,
|
||||
UserAgent: "Bose-SoundTouch-Go-Client/1.0",
|
||||
MigrationEnabled: true, // TODO: Change to false after 3-4 releases
|
||||
MigrationDryRun: false,
|
||||
CacheEnabled: true,
|
||||
CacheTTL: 30 * time.Second,
|
||||
}
|
||||
|
||||
+46
-111
@@ -4,7 +4,6 @@ package discovery
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -15,7 +14,7 @@ import (
|
||||
// DNSDiscovery handles DNS queries and records discovered hosts.
|
||||
type DNSDiscovery struct {
|
||||
// Configuration
|
||||
upstreamDNS []string
|
||||
upstreamDNS string
|
||||
serviceIP string
|
||||
|
||||
// State
|
||||
@@ -32,9 +31,6 @@ type DNSDiscovery struct {
|
||||
// Address for loop prevention
|
||||
bindAddr string
|
||||
|
||||
// Forward timeout
|
||||
timeout time.Duration
|
||||
|
||||
// Log throttling
|
||||
lastLog map[string]time.Time
|
||||
lastLogMu sync.Mutex
|
||||
@@ -52,12 +48,11 @@ type DiscoveredHost struct {
|
||||
}
|
||||
|
||||
// NewDNSDiscovery creates a new DNSDiscovery instance.
|
||||
func NewDNSDiscovery(upstreamDNS []string, serviceIP string) *DNSDiscovery {
|
||||
func NewDNSDiscovery(upstreamDNS, serviceIP string) *DNSDiscovery {
|
||||
return &DNSDiscovery{
|
||||
upstreamDNS: upstreamDNS,
|
||||
serviceIP: serviceIP,
|
||||
discovered: make(map[string]*DiscoveredHost),
|
||||
timeout: 2 * time.Second,
|
||||
lastLog: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
@@ -88,7 +83,7 @@ func (d *DNSDiscovery) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
d.throttledLog(fmt.Sprintf("[DNS] Intercepting %s (type %d) -> %s", hostname, q.Qtype, d.serviceIP))
|
||||
} else {
|
||||
// Forward to real DNS
|
||||
if len(d.upstreamDNS) == 0 {
|
||||
if d.upstreamDNS == "" {
|
||||
d.throttledLog("[DNS ERROR] No upstream DNS configured, cannot forward")
|
||||
|
||||
m := new(dns.Msg)
|
||||
@@ -99,7 +94,7 @@ func (d *DNSDiscovery) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
return
|
||||
}
|
||||
|
||||
d.throttledLog(fmt.Sprintf("[DNS] Forwarding %s (type %d) to %v", hostname, q.Qtype, d.upstreamDNS))
|
||||
d.throttledLog(fmt.Sprintf("[DNS] Forwarding %s (type %d) to %s", hostname, q.Qtype, d.upstreamDNS))
|
||||
d.forward(w, r)
|
||||
}
|
||||
}
|
||||
@@ -160,7 +155,6 @@ func (d *DNSDiscovery) shouldIntercept(hostname string) bool {
|
||||
"marge.bose.com",
|
||||
"bmx.bose.com",
|
||||
"streaming.bose.com",
|
||||
"streamingoauth.bose.com",
|
||||
"updates.bose.com",
|
||||
"stats.bose.com",
|
||||
"content.api.bose.io",
|
||||
@@ -197,78 +191,19 @@ func (d *DNSDiscovery) respondWithIP(w dns.ResponseWriter, r *dns.Msg, ip string
|
||||
q := r.Question[0]
|
||||
log.Printf("[DNS] Intercepted query for %s (type %d) from %s", q.Name, q.Qtype, w.RemoteAddr())
|
||||
|
||||
resolvedIP := ip
|
||||
if net.ParseIP(ip) == nil {
|
||||
// Attempt resolution if it's not a numeric IP
|
||||
ips, err := net.LookupIP(ip)
|
||||
if err == nil && len(ips) > 0 {
|
||||
for _, rIP := range ips {
|
||||
if rIP.To4() != nil {
|
||||
resolvedIP = rIP.String()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if resolvedIP == ip && len(ips) > 0 {
|
||||
resolvedIP = ips[0].String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch q.Qtype {
|
||||
case dns.TypeA, dns.TypeANY:
|
||||
if net.ParseIP(resolvedIP) == nil || strings.Contains(resolvedIP, ":") {
|
||||
// If it's still not a valid IPv4 address, we can't create an A record.
|
||||
// Try CNAME as a fallback if it looks like a hostname.
|
||||
if !strings.Contains(resolvedIP, ":") {
|
||||
// Normalize hostname for CNAME
|
||||
target := resolvedIP
|
||||
if !strings.HasSuffix(target, ".") {
|
||||
target += "."
|
||||
}
|
||||
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN A %s", q.Name, ip))
|
||||
if err == nil {
|
||||
m.Answer = append(m.Answer, rr)
|
||||
|
||||
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN CNAME %s", q.Name, target))
|
||||
if err == nil {
|
||||
m.Answer = append(m.Answer, rr)
|
||||
|
||||
log.Printf("[DNS] Returning CNAME record %s -> %s", q.Name, target)
|
||||
} else {
|
||||
log.Printf("[DNS] Error creating CNAME fallback for %s: %v", target, err)
|
||||
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
}
|
||||
} else {
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
}
|
||||
log.Printf("[DNS] Returning A record %s -> %s", q.Name, ip)
|
||||
} else {
|
||||
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN A %s", q.Name, resolvedIP))
|
||||
if err == nil {
|
||||
m.Answer = append(m.Answer, rr)
|
||||
|
||||
log.Printf("[DNS] Returning A record %s -> %s", q.Name, resolvedIP)
|
||||
} else {
|
||||
log.Printf("[DNS] Error creating A record for %s: %v", resolvedIP, err)
|
||||
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
}
|
||||
log.Printf("[DNS] Error creating A record: %v", err)
|
||||
}
|
||||
case dns.TypeAAAA:
|
||||
// Check if we have an IPv6 address
|
||||
if net.ParseIP(resolvedIP) != nil && strings.Contains(resolvedIP, ":") {
|
||||
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN AAAA %s", q.Name, resolvedIP))
|
||||
if err == nil {
|
||||
m.Answer = append(m.Answer, rr)
|
||||
|
||||
log.Printf("[DNS] Returning AAAA record %s -> %s", q.Name, resolvedIP)
|
||||
} else {
|
||||
log.Printf("[DNS] Error creating AAAA record for %s: %v", resolvedIP, err)
|
||||
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
}
|
||||
} else {
|
||||
// Explicitly return SUCCESS with no data for AAAA to prevent fallback issues if no IPv6
|
||||
log.Printf("[DNS] Returning empty AAAA success (NODATA) for %s", q.Name)
|
||||
}
|
||||
// Explicitly return SUCCESS with no data for AAAA to prevent fallback issues
|
||||
log.Printf("[DNS] Returning empty AAAA success (NODATA) for %s", q.Name)
|
||||
default:
|
||||
log.Printf("[DNS] Returning empty success for type %d", q.Qtype)
|
||||
}
|
||||
@@ -298,44 +233,44 @@ func (d *DNSDiscovery) forward(w dns.ResponseWriter, r *dns.Msg) {
|
||||
return
|
||||
}
|
||||
|
||||
c := new(dns.Client)
|
||||
c.Timeout = d.timeout
|
||||
|
||||
for _, upstream := range d.upstreamDNS {
|
||||
// Add port 53 if not present
|
||||
if !strings.Contains(upstream, ":") {
|
||||
upstream += ":53"
|
||||
}
|
||||
|
||||
// Loop prevention: don't forward to ourselves
|
||||
if upstream == d.bindAddr || (strings.HasPrefix(upstream, "127.0.0.1:") && strings.HasSuffix(d.bindAddr, upstream[9:])) {
|
||||
d.throttledLog(fmt.Sprintf("[DNS ERROR] Refusing to forward %s to ourselves (%s)", q.Name, upstream))
|
||||
continue
|
||||
}
|
||||
|
||||
in, _, err := c.Exchange(r, upstream)
|
||||
if err == nil {
|
||||
if in.Rcode == dns.RcodeSuccess {
|
||||
if writeErr := w.WriteMsg(in); writeErr != nil {
|
||||
log.Printf("[DNS ERROR] Failed to write forwarded response from %s: %v", upstream, writeErr)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
d.throttledLog(fmt.Sprintf("[DNS] Upstream %s returned %s for %s, trying next", upstream, dns.RcodeToString[in.Rcode], q.Name))
|
||||
} else {
|
||||
d.throttledLog(fmt.Sprintf("[DNS ERROR] Forward failed for %s (type %d) via %s: %v", q.Name, q.Qtype, upstream, err))
|
||||
}
|
||||
// Add port 53 if not present
|
||||
upstream := d.upstreamDNS
|
||||
if !strings.Contains(upstream, ":") {
|
||||
upstream += ":53"
|
||||
}
|
||||
|
||||
// If we reach here, all upstreams failed
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
// Loop prevention: don't forward to ourselves
|
||||
if upstream == d.bindAddr || (strings.HasPrefix(upstream, "127.0.0.1:") && strings.HasSuffix(d.bindAddr, upstream[9:])) {
|
||||
d.throttledLog(fmt.Sprintf("[DNS ERROR] Refusing to forward %s to ourselves (%s)", q.Name, upstream))
|
||||
|
||||
if err := w.WriteMsg(m); err != nil {
|
||||
log.Printf("[DNS ERROR] Failed to write failure response: %v", err)
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
_ = w.WriteMsg(m)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c := new(dns.Client)
|
||||
c.Timeout = 2 * time.Second
|
||||
|
||||
in, _, err := c.Exchange(r, upstream)
|
||||
if err != nil {
|
||||
d.throttledLog(fmt.Sprintf("[DNS ERROR] Forward failed for %s (type %d): %v", q.Name, q.Qtype, err))
|
||||
// Return a failure response instead of just dropping
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
if err := w.WriteMsg(m); err != nil {
|
||||
log.Printf("[DNS ERROR] Failed to write failure response: %v", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err := w.WriteMsg(in); err != nil {
|
||||
log.Printf("[DNS ERROR] Failed to write forwarded response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-250
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
func TestDNSDiscovery_Interception(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
upstreamDNS := []string{"8.8.8.8"}
|
||||
upstreamDNS := "8.8.8.8"
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
// Test intercepting Bose service
|
||||
@@ -38,24 +38,6 @@ func TestDNSDiscovery_Interception(t *testing.T) {
|
||||
t.Errorf("Expected A record, got %T", rw.msg.Answer[0])
|
||||
}
|
||||
|
||||
// Test intercepting streamingoauth.bose.com
|
||||
m3 := new(dns.Msg)
|
||||
m3.SetQuestion("streamingoauth.bose.com.", dns.TypeA)
|
||||
rw3 := &mockResponseWriter{}
|
||||
d.ServeDNS(rw3, m3)
|
||||
|
||||
if rw3.msg == nil || len(rw3.msg.Answer) == 0 {
|
||||
t.Fatal("Expected response for streamingoauth.bose.com")
|
||||
}
|
||||
|
||||
if a, ok := rw3.msg.Answer[0].(*dns.A); ok {
|
||||
if a.A.String() != serviceIP {
|
||||
t.Errorf("Expected intercepted IP %s for streamingoauth.bose.com, got %s", serviceIP, a.A.String())
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Expected A record for streamingoauth.bose.com, got %T", rw3.msg.Answer[0])
|
||||
}
|
||||
|
||||
// Test aftertouch.test
|
||||
m2 := new(dns.Msg)
|
||||
m2.SetQuestion("aftertouch.test.", dns.TypeA)
|
||||
@@ -79,7 +61,7 @@ func TestDNSDiscovery_Forwarding(t *testing.T) {
|
||||
// This test is harder because it needs a real upstream or a mock.
|
||||
// For now, let's just test that it calls forward and record.
|
||||
serviceIP := "192.168.1.100"
|
||||
upstreamDNS := []string{"127.0.0.1:5353"} // Use a port that is likely closed or we can mock
|
||||
upstreamDNS := "127.0.0.1:5353" // Use a port that is likely closed or we can mock
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
m := new(dns.Msg)
|
||||
@@ -120,7 +102,7 @@ func TestDNSDiscovery_Forwarding(t *testing.T) {
|
||||
|
||||
func TestDNSDiscovery_StartTCP(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
upstreamDNS := []string{"8.8.8.8"}
|
||||
upstreamDNS := "8.8.8.8"
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
addr := "127.0.0.1:5354"
|
||||
@@ -167,109 +149,9 @@ func TestDNSDiscovery_StartTCP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_SelfForwarding(t *testing.T) {
|
||||
serviceIP := "soundtouch.local"
|
||||
upstreamDNS := []string{"127.0.0.1:5357"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
// Mock upstream DNS server for soundtouch.local
|
||||
mux := dns.NewServeMux()
|
||||
mux.HandleFunc("soundtouch.local.", func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
rr, _ := dns.NewRR("soundtouch.local. 60 IN A 192.168.178.10")
|
||||
m.Answer = append(m.Answer, rr)
|
||||
_ = w.WriteMsg(m)
|
||||
})
|
||||
ts := &dns.Server{Addr: "127.0.0.1:5357", Net: "udp", Handler: mux, ReadTimeout: 100 * time.Millisecond, WriteTimeout: 100 * time.Millisecond}
|
||||
go func() {
|
||||
_ = ts.ListenAndServe()
|
||||
}()
|
||||
defer func() { _ = ts.Shutdown() }()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("soundtouch.local.", dns.TypeA)
|
||||
rw := &mockResponseWriter{}
|
||||
d.ServeDNS(rw, m)
|
||||
|
||||
if rw.msg == nil {
|
||||
t.Fatal("Expected a response for soundtouch.local")
|
||||
}
|
||||
|
||||
if rw.msg.Rcode != dns.RcodeSuccess {
|
||||
t.Errorf("Expected Success (0) for soundtouch.local being forwarded, got %d", rw.msg.Rcode)
|
||||
}
|
||||
|
||||
if len(rw.msg.Answer) == 0 {
|
||||
t.Fatal("Expected an answer in the response")
|
||||
}
|
||||
|
||||
if a, ok := rw.msg.Answer[0].(*dns.A); ok {
|
||||
if a.A.String() != "192.168.178.10" {
|
||||
t.Errorf("Expected IP 192.168.178.10, got %s", a.A.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Check if d.recordQuery logged it correctly.
|
||||
d.mu.RLock()
|
||||
host, exists := d.discovered["soundtouch.local"]
|
||||
d.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
t.Error("Expected soundtouch.local to be recorded")
|
||||
}
|
||||
// It should NOT be intercepted anymore
|
||||
if host != nil && host.IsIntercepted {
|
||||
t.Error("Expected soundtouch.local NOT to be intercepted anymore, but forwarded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_ForwardLocal(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
upstreamDNS := []string{"127.0.0.1:5356"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("someone-else.local.", dns.TypeA)
|
||||
rw := &mockResponseWriter{}
|
||||
|
||||
// Start a mock upstream DNS server that returns SUCCESS for .local
|
||||
mux := dns.NewServeMux()
|
||||
mux.HandleFunc("someone-else.local.", func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
rr, _ := dns.NewRR("someone-else.local. 60 IN A 192.168.1.50")
|
||||
m.Answer = append(m.Answer, rr)
|
||||
_ = w.WriteMsg(m)
|
||||
})
|
||||
ts := &dns.Server{Addr: "127.0.0.1:5356", Net: "udp", Handler: mux, ReadTimeout: 100 * time.Millisecond, WriteTimeout: 100 * time.Millisecond}
|
||||
go func() {
|
||||
_ = ts.ListenAndServe()
|
||||
}()
|
||||
defer func() { _ = ts.Shutdown() }()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
d.ServeDNS(rw, m)
|
||||
|
||||
if rw.msg == nil {
|
||||
t.Fatal("Expected a response message")
|
||||
}
|
||||
|
||||
if rw.msg.Rcode != dns.RcodeSuccess {
|
||||
t.Errorf("Expected Success (0) for .local being forwarded, got %d", rw.msg.Rcode)
|
||||
}
|
||||
|
||||
if len(rw.msg.Answer) == 0 {
|
||||
t.Fatal("Expected an answer in the response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_IsRunning(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
upstreamDNS := []string{"8.8.8.8"}
|
||||
upstreamDNS := "8.8.8.8"
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
addr := "127.0.0.1:5355"
|
||||
@@ -314,7 +196,7 @@ func (m *mockResponseWriter) TsigTimersOnly(bool) {}
|
||||
func (m *mockResponseWriter) Hijack() {}
|
||||
|
||||
func TestDNSDiscovery_LogThrottling(t *testing.T) {
|
||||
d := NewDNSDiscovery([]string{"8.8.8.8"}, "192.168.1.100")
|
||||
d := NewDNSDiscovery("8.8.8.8", "192.168.1.100")
|
||||
|
||||
// Capture log output
|
||||
var logBuf strings.Builder
|
||||
@@ -347,7 +229,7 @@ func TestDNSDiscovery_LogThrottling(t *testing.T) {
|
||||
func TestDNSDiscovery_LoopPrevention(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
bindAddr := "127.0.0.1:53"
|
||||
upstreamDNS := []string{"127.0.0.1:53"}
|
||||
upstreamDNS := "127.0.0.1:53"
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
d.bindAddr = bindAddr
|
||||
|
||||
@@ -374,7 +256,7 @@ func TestDNSDiscovery_LoopPrevention(t *testing.T) {
|
||||
|
||||
func TestDNSDiscovery_EmptyUpstream(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
var upstreamDNS []string // Empty upstream
|
||||
upstreamDNS := "" // Empty upstream
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
d.bindAddr = ":53"
|
||||
|
||||
@@ -398,9 +280,8 @@ func TestDNSDiscovery_EmptyUpstream(t *testing.T) {
|
||||
func TestDNSDiscovery_ForwardTimeout(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
// Use an IP that is unroutable or doesn't exist on the network to ensure timeout
|
||||
upstreamDNS := []string{"192.0.2.1:53"} // TEST-NET-1, usually non-routable
|
||||
upstreamDNS := "192.0.2.1:53" // TEST-NET-1, usually non-routable
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
d.timeout = 100 * time.Millisecond
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("google.com.", dns.TypeA)
|
||||
@@ -411,132 +292,11 @@ func TestDNSDiscovery_ForwardTimeout(t *testing.T) {
|
||||
d.forward(rw, m)
|
||||
duration := time.Since(start)
|
||||
|
||||
if duration < 100*time.Millisecond {
|
||||
t.Errorf("Expected forward to take at least 100ms (timeout), but took %v", duration)
|
||||
if duration < 2*time.Second {
|
||||
t.Errorf("Expected forward to take at least 2 seconds (timeout), but took %v", duration)
|
||||
}
|
||||
|
||||
if rw.msg == nil || rw.msg.Rcode != dns.RcodeServerFailure {
|
||||
t.Errorf("Expected RcodeServerFailure after timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_MultipleUpstreams(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
|
||||
// Mock server 1: returns NXDOMAIN
|
||||
mux1 := dns.NewServeMux()
|
||||
mux1.HandleFunc("test.com.", func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
m.Rcode = dns.RcodeNameError
|
||||
_ = w.WriteMsg(m)
|
||||
})
|
||||
ts1 := &dns.Server{Addr: "127.0.0.1:5356", Net: "udp", Handler: mux1}
|
||||
go func() { _ = ts1.ListenAndServe() }()
|
||||
defer func() { _ = ts1.Shutdown() }()
|
||||
|
||||
// Mock server 2: succeeds
|
||||
mux2 := dns.NewServeMux()
|
||||
mux2.HandleFunc("test.com.", func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
m.Answer = append(m.Answer, &dns.A{
|
||||
Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
|
||||
A: net.ParseIP("1.2.3.4"),
|
||||
})
|
||||
_ = w.WriteMsg(m)
|
||||
})
|
||||
ts2 := &dns.Server{Addr: "127.0.0.1:5357", Net: "udp", Handler: mux2}
|
||||
go func() { _ = ts2.ListenAndServe() }()
|
||||
defer func() { _ = ts2.Shutdown() }()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
upstreamDNS := []string{"127.0.0.1:5356", "127.0.0.1:5357"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("test.com.", dns.TypeA)
|
||||
rw := &mockResponseWriter{}
|
||||
|
||||
d.forward(rw, m)
|
||||
|
||||
if rw.msg == nil {
|
||||
t.Fatal("Expected a response message")
|
||||
}
|
||||
|
||||
// It should succeed because it falls back to the second upstream
|
||||
if rw.msg.Rcode != dns.RcodeSuccess {
|
||||
t.Errorf("Expected RcodeSuccess (0), got %d. Fallback failed.", rw.msg.Rcode)
|
||||
}
|
||||
|
||||
if len(rw.msg.Answer) == 0 {
|
||||
t.Fatal("Expected an answer from the second upstream")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_HostnameServiceIP(t *testing.T) {
|
||||
// Use localhost which should resolve to 127.0.0.1
|
||||
serviceIP := "localhost"
|
||||
upstreamDNS := []string{"8.8.8.8"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("api.bose.com.", dns.TypeA)
|
||||
|
||||
rw := &mockResponseWriter{}
|
||||
d.ServeDNS(rw, m)
|
||||
|
||||
if rw.msg == nil {
|
||||
t.Fatal("Expected a response message, got nil")
|
||||
}
|
||||
|
||||
if len(rw.msg.Answer) == 0 {
|
||||
t.Fatal("Expected an answer in the response")
|
||||
}
|
||||
|
||||
if a, ok := rw.msg.Answer[0].(*dns.A); ok {
|
||||
// It should be resolved to 127.0.0.1 (or whatever localhost resolves to)
|
||||
if a.A.String() == "" {
|
||||
t.Error("Expected a non-empty IP address")
|
||||
}
|
||||
log.Printf("Resolved localhost to %s", a.A.String())
|
||||
} else if cname, ok := rw.msg.Answer[0].(*dns.CNAME); ok {
|
||||
// Fallback to CNAME is also acceptable if resolution failed but it shouldn't for localhost
|
||||
if cname.Target != "localhost." {
|
||||
t.Errorf("Expected CNAME to localhost., got %s", cname.Target)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Expected A or CNAME record, got %T", rw.msg.Answer[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_UnresolvableHostname(t *testing.T) {
|
||||
// Use a likely unresolvable hostname
|
||||
serviceIP := "this.hostname.does.not.exist.at.all.invalid"
|
||||
upstreamDNS := []string{"8.8.8.8"}
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("api.bose.com.", dns.TypeA)
|
||||
|
||||
rw := &mockResponseWriter{}
|
||||
d.ServeDNS(rw, m)
|
||||
|
||||
if rw.msg == nil {
|
||||
t.Fatal("Expected a response message, got nil")
|
||||
}
|
||||
|
||||
if len(rw.msg.Answer) == 0 {
|
||||
t.Fatal("Expected an answer in the response (CNAME fallback)")
|
||||
}
|
||||
|
||||
if cname, ok := rw.msg.Answer[0].(*dns.CNAME); ok {
|
||||
expected := serviceIP + "."
|
||||
if cname.Target != expected {
|
||||
t.Errorf("Expected CNAME to %s, got %s", expected, cname.Target)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Expected CNAME record for unresolvable hostname, got %T", rw.msg.Answer[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,9 @@ func TestNewMDNSDiscoveryService(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMDNSDiscoverDevices(t *testing.T) {
|
||||
service := NewMDNSDiscoveryService(100 * time.Millisecond)
|
||||
service := NewMDNSDiscoveryService(2 * time.Second)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Note: This test will attempt actual mDNS discovery
|
||||
|
||||
@@ -78,11 +78,11 @@ func TestUnifiedDiscoveryWithCustomConfig(t *testing.T) {
|
||||
|
||||
func TestUnifiedDiscoverDevices(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.DiscoveryTimeout = 100 * time.Millisecond
|
||||
cfg.DiscoveryTimeout = 2 * time.Second
|
||||
cfg.CacheEnabled = false // Disable cache for testing
|
||||
service := NewUnifiedDiscoveryService(cfg)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
devices, err := service.DiscoverDevices(ctx)
|
||||
@@ -119,13 +119,13 @@ func TestUnifiedDiscoverDevices(t *testing.T) {
|
||||
|
||||
func TestUnifiedDiscoveryOnlyMDNS(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.DiscoveryTimeout = 100 * time.Millisecond
|
||||
cfg.DiscoveryTimeout = 1 * time.Second
|
||||
cfg.UPnPEnabled = false // Disable UPnP
|
||||
cfg.MDNSEnabled = true // Enable only mDNS
|
||||
cfg.CacheEnabled = false
|
||||
service := NewUnifiedDiscoveryService(cfg)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
devices, err := service.DiscoverDevices(ctx)
|
||||
@@ -141,13 +141,13 @@ func TestUnifiedDiscoveryOnlyMDNS(t *testing.T) {
|
||||
|
||||
func TestUnifiedDiscoveryOnlySSDP(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.DiscoveryTimeout = 100 * time.Millisecond
|
||||
cfg.DiscoveryTimeout = 1 * time.Second
|
||||
cfg.UPnPEnabled = true // Enable only UPnP
|
||||
cfg.MDNSEnabled = false // Disable mDNS
|
||||
cfg.CacheEnabled = false
|
||||
service := NewUnifiedDiscoveryService(cfg)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
devices, err := service.DiscoverDevices(ctx)
|
||||
|
||||
+25
-62
@@ -2,10 +2,8 @@ package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -20,12 +18,11 @@ import (
|
||||
|
||||
// Service handles UPnP SSDP discovery of SoundTouch devices
|
||||
type Service struct {
|
||||
timeout time.Duration
|
||||
cache map[string]*models.DiscoveredDevice
|
||||
cacheTTL time.Duration
|
||||
mutex sync.RWMutex
|
||||
config *config.Config
|
||||
httpClient *http.Client
|
||||
timeout time.Duration
|
||||
cache map[string]*models.DiscoveredDevice
|
||||
cacheTTL time.Duration
|
||||
mutex sync.RWMutex
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// NewService creates a new UPnP discovery service
|
||||
@@ -35,12 +32,11 @@ func NewService(timeout time.Duration) *Service {
|
||||
}
|
||||
|
||||
return &Service{
|
||||
timeout: timeout,
|
||||
cache: make(map[string]*models.DiscoveredDevice),
|
||||
cacheTTL: defaultCacheTTL,
|
||||
mutex: sync.RWMutex{},
|
||||
config: config.DefaultConfig(),
|
||||
httpClient: &http.Client{Timeout: 5 * time.Second},
|
||||
timeout: timeout,
|
||||
cache: make(map[string]*models.DiscoveredDevice),
|
||||
cacheTTL: defaultCacheTTL,
|
||||
mutex: sync.RWMutex{},
|
||||
config: config.DefaultConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,12 +53,11 @@ func NewServiceWithConfig(cfg *config.Config) *Service {
|
||||
}
|
||||
|
||||
return &Service{
|
||||
timeout: timeout,
|
||||
cache: make(map[string]*models.DiscoveredDevice),
|
||||
cacheTTL: cacheTTL,
|
||||
mutex: sync.RWMutex{},
|
||||
config: cfg,
|
||||
httpClient: &http.Client{Timeout: 5 * time.Second},
|
||||
timeout: timeout,
|
||||
cache: make(map[string]*models.DiscoveredDevice),
|
||||
cacheTTL: cacheTTL,
|
||||
mutex: sync.RWMutex{},
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,7 +374,7 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro
|
||||
log.Printf("UPnP: Successfully parsed device from location: %s at %s:%d", device.Name, device.Host, device.Port)
|
||||
|
||||
// Try to get more device info from the location URL
|
||||
if err := d.EnrichDeviceInfo(device, location); err != nil {
|
||||
if err := d.enrichDeviceInfo(device, location); err != nil {
|
||||
log.Printf("UPnP: Could not enrich device info from location '%s': %v", location, err)
|
||||
// Don't fail if we can't get additional info
|
||||
// The basic info from URL parsing should be sufficient
|
||||
@@ -422,11 +417,15 @@ func (d *Service) parseLocationURL(location, usn string) (*models.DiscoveredDevi
|
||||
return device, nil
|
||||
}
|
||||
|
||||
// EnrichDeviceInfo tries to get additional device information from the device description
|
||||
func (d *Service) EnrichDeviceInfo(device *models.DiscoveredDevice, location string) error {
|
||||
// enrichDeviceInfo tries to get additional device information from the device description
|
||||
func (d *Service) enrichDeviceInfo(_ *models.DiscoveredDevice, location string) error {
|
||||
log.Printf("UPnP: Attempting to enrich device info by fetching %s", location)
|
||||
|
||||
resp, err := d.httpClient.Get(location)
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Get(location)
|
||||
if err != nil {
|
||||
log.Printf("UPnP: Failed to fetch device description from %s: %v", location, err)
|
||||
return err
|
||||
@@ -438,44 +437,8 @@ func (d *Service) EnrichDeviceInfo(device *models.DiscoveredDevice, location str
|
||||
|
||||
log.Printf("UPnP: Successfully fetched device description from %s (Status: %s)", location, resp.Status)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read body: %w", err)
|
||||
}
|
||||
|
||||
var upnpRoot struct {
|
||||
XMLName xml.Name `xml:"root"`
|
||||
Device struct {
|
||||
FriendlyName string `xml:"friendlyName"`
|
||||
ModelName string `xml:"modelName"`
|
||||
SerialNumber string `xml:"serialNumber"`
|
||||
} `xml:"device"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(data, &upnpRoot); err != nil {
|
||||
log.Printf("UPnP: Failed to parse device description from %s: %v", location, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if upnpRoot.Device.FriendlyName != "" {
|
||||
device.Name = upnpRoot.Device.FriendlyName
|
||||
}
|
||||
|
||||
if upnpRoot.Device.ModelName != "" {
|
||||
device.ModelID = upnpRoot.Device.ModelName
|
||||
}
|
||||
|
||||
if upnpRoot.Device.SerialNumber != "" {
|
||||
device.UPnPSerial = upnpRoot.Device.SerialNumber
|
||||
}
|
||||
|
||||
log.Printf("UPnP: Enriched device info: Name='%s', Model='%s', UPnPSerial='%s'",
|
||||
device.Name, device.ModelID, device.UPnPSerial)
|
||||
|
||||
// For now, we'll keep it simple and not parse the full UPnP device description
|
||||
// This can be enhanced later to extract more detailed device information
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestEnrichDeviceInfo(t *testing.T) {
|
||||
// Mock UPnP device description XML
|
||||
xmlData := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<device>
|
||||
<friendlyName>Sound Machinery</friendlyName>
|
||||
<modelName>SoundTouch 10</modelName>
|
||||
<serialNumber>A81B6A536A09</serialNumber>
|
||||
</device>
|
||||
</root>`
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
fmt.Fprint(w, xmlData)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
device := &models.DiscoveredDevice{
|
||||
Host: "127.0.0.1",
|
||||
Name: "Initial Name",
|
||||
}
|
||||
|
||||
service := NewService(1 * time.Second)
|
||||
service.httpClient = server.Client()
|
||||
err := service.EnrichDeviceInfo(device, server.URL)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("enrichDeviceInfo failed: %v", err)
|
||||
}
|
||||
|
||||
if device.Name != "Sound Machinery" {
|
||||
t.Errorf("expected Name 'Sound Machinery', got '%s'", device.Name)
|
||||
}
|
||||
|
||||
if device.ModelID != "SoundTouch 10" {
|
||||
t.Errorf("expected ModelID 'SoundTouch 10', got '%s'", device.ModelID)
|
||||
}
|
||||
|
||||
if device.UPnPSerial != "A81B6A536A09" {
|
||||
t.Errorf("expected UPnPSerial 'A81B6A536A09', got '%s'", device.UPnPSerial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUPnP_Unmarshal(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<device>
|
||||
<friendlyName>Sound Machinery</friendlyName>
|
||||
<modelName>SoundTouch 10</modelName>
|
||||
<serialNumber>A81B6A536A09</serialNumber>
|
||||
</device>
|
||||
</root>`
|
||||
|
||||
var upnpRoot struct {
|
||||
XMLName xml.Name `xml:"root"`
|
||||
Device struct {
|
||||
FriendlyName string `xml:"friendlyName"`
|
||||
ModelName string `xml:"modelName"`
|
||||
SerialNumber string `xml:"serialNumber"`
|
||||
} `xml:"device"`
|
||||
}
|
||||
|
||||
err := xml.Unmarshal([]byte(data), &upnpRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if upnpRoot.Device.FriendlyName != "Sound Machinery" {
|
||||
t.Errorf("expected FriendlyName 'Sound Machinery', got '%s'", upnpRoot.Device.FriendlyName)
|
||||
}
|
||||
if upnpRoot.Device.ModelName != "SoundTouch 10" {
|
||||
t.Errorf("expected ModelName 'SoundTouch 10', got '%s'", upnpRoot.Device.ModelName)
|
||||
}
|
||||
if upnpRoot.Device.SerialNumber != "A81B6A536A09" {
|
||||
t.Errorf("expected SerialNumber 'A81B6A536A09', got '%s'", upnpRoot.Device.SerialNumber)
|
||||
}
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestUPnP_EnrichDeviceInfo_RealDeviceXML(t *testing.T) {
|
||||
// This tests the exact UPnP XML format provided by the user
|
||||
realDeviceXML := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<specVersion>
|
||||
<major>1</major>
|
||||
<minor>0</minor>
|
||||
</specVersion>
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
|
||||
<friendlyName>Sound Machinechen</friendlyName>
|
||||
<qq:X_QPlay_SoftwareCapability xmlns:qq="http://www.tencent.com">QPlay:2</qq:X_QPlay_SoftwareCapability>
|
||||
<manufacturer>Bose Corporation</manufacturer>
|
||||
<manufacturerURL>http://www.bose.com</manufacturerURL>
|
||||
<modelName>SoundTouch 10</modelName>
|
||||
<modelNumber></modelNumber>
|
||||
<modelDescription>Bose SoundTouch Wireless Streaming Audio Device</modelDescription>
|
||||
<modelURL>http://www.bose.com</modelURL>
|
||||
<serialNumber>A81B6A536A98</serialNumber>
|
||||
<UDN>uuid:BO5EBO5E-F00D-F00D-FEED-A81B6A536A98</UDN>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
|
||||
<serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
|
||||
<SCPDURL>/Xml/AVTransport3.xml</SCPDURL>
|
||||
<controlURL>/AVTransport/Control</controlURL>
|
||||
<eventSubURL>/AVTransport/Event</eventSubURL>
|
||||
</service>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
|
||||
<serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
|
||||
<SCPDURL>/Xml/ConnectionManager3.xml</SCPDURL>
|
||||
<controlURL>/ConnectionManager/Control</controlURL>
|
||||
<eventSubURL>/ConnectionManager/Event</eventSubURL>
|
||||
</service>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:RenderingControl:1</serviceType>
|
||||
<serviceId>urn:upnp-org:serviceId:RenderingControl</serviceId>
|
||||
<SCPDURL>/Xml/RenderingControl3.xml</SCPDURL>
|
||||
<controlURL>/RenderingControl/Control</controlURL>
|
||||
<eventSubURL>/RenderingControl/Event</eventSubURL>
|
||||
</service>
|
||||
<service>
|
||||
<serviceType>urn:schemas-tencent-com:service:QPlay:2</serviceType>
|
||||
<serviceId>urn:tencent-com:serviceId:QPlay</serviceId>
|
||||
<controlURL>/QPlay/Control</controlURL>
|
||||
<eventSubURL>/QPlay/Event</eventSubURL>
|
||||
<SCPDURL>/Xml/QPlay.xml</SCPDURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</root>`
|
||||
|
||||
// Create a test server that serves the UPnP XML
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
|
||||
fmt.Fprint(w, realDeviceXML)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create a discovered device to enrich
|
||||
device := &models.DiscoveredDevice{
|
||||
Host: "192.168.1.100",
|
||||
Port: 8091,
|
||||
Name: "Initial Device Name",
|
||||
}
|
||||
|
||||
// Create discovery service and enrich the device
|
||||
service := NewService(5 * time.Second)
|
||||
err := service.EnrichDeviceInfo(device, server.URL)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("enrichDeviceInfo failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify that the MAC address was extracted correctly from serialNumber
|
||||
expectedMAC := "A81B6A536A98"
|
||||
if device.UPnPSerial != expectedMAC {
|
||||
t.Errorf("Expected UPnPSerial '%s', got '%s'", expectedMAC, device.UPnPSerial)
|
||||
}
|
||||
|
||||
// Verify other enriched fields
|
||||
expectedName := "Sound Machinechen"
|
||||
if device.Name != expectedName {
|
||||
t.Errorf("Expected Name '%s', got '%s'", expectedName, device.Name)
|
||||
}
|
||||
|
||||
expectedModel := "SoundTouch 10"
|
||||
if device.ModelID != expectedModel {
|
||||
t.Errorf("Expected ModelID '%s', got '%s'", expectedModel, device.ModelID)
|
||||
}
|
||||
|
||||
t.Logf("✓ Successfully extracted MAC address '%s' from UPnP serialNumber field", device.UPnPSerial)
|
||||
t.Logf("✓ Device name: '%s'", device.Name)
|
||||
t.Logf("✓ Device model: '%s'", device.ModelID)
|
||||
}
|
||||
|
||||
func TestUPnP_MACAddressDiscovery_Integration(t *testing.T) {
|
||||
// Test various MAC address formats that might appear in serialNumber
|
||||
testCases := []struct {
|
||||
name string
|
||||
serialNumberInXML string
|
||||
expectedUPnPSerial string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "StandardMAC",
|
||||
serialNumberInXML: "A81B6A536A98",
|
||||
expectedUPnPSerial: "A81B6A536A98",
|
||||
description: "Standard MAC address format without separators",
|
||||
},
|
||||
{
|
||||
name: "MACWithColons",
|
||||
serialNumberInXML: "A8:1B:6A:53:6A:98",
|
||||
expectedUPnPSerial: "A8:1B:6A:53:6A:98",
|
||||
description: "MAC address with colon separators",
|
||||
},
|
||||
{
|
||||
name: "MACWithDashes",
|
||||
serialNumberInXML: "A8-1B-6A-53-6A-98",
|
||||
expectedUPnPSerial: "A8-1B-6A-53-6A-98",
|
||||
description: "MAC address with dash separators",
|
||||
},
|
||||
{
|
||||
name: "LowercaseMAC",
|
||||
serialNumberInXML: "a81b6a536a98",
|
||||
expectedUPnPSerial: "a81b6a536a98",
|
||||
description: "Lowercase MAC address",
|
||||
},
|
||||
{
|
||||
name: "MixedCaseMAC",
|
||||
serialNumberInXML: "a81B6a536A98",
|
||||
expectedUPnPSerial: "a81B6a536A98",
|
||||
description: "Mixed case MAC address",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create UPnP XML with the specific serialNumber format
|
||||
xmlTemplate := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
|
||||
<friendlyName>Test Device</friendlyName>
|
||||
<manufacturer>Bose Corporation</manufacturer>
|
||||
<modelName>SoundTouch 10</modelName>
|
||||
<serialNumber>%s</serialNumber>
|
||||
<UDN>uuid:BO5EBO5E-F00D-F00D-FEED-TEST</UDN>
|
||||
</device>
|
||||
</root>`
|
||||
|
||||
deviceXML := fmt.Sprintf(xmlTemplate, tc.serialNumberInXML)
|
||||
|
||||
// Create test server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
fmt.Fprint(w, deviceXML)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create and enrich device
|
||||
device := &models.DiscoveredDevice{
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
}
|
||||
|
||||
service := NewService(5 * time.Second)
|
||||
err := service.EnrichDeviceInfo(device, server.URL)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("%s: enrichDeviceInfo failed: %v", tc.description, err)
|
||||
return
|
||||
}
|
||||
|
||||
if device.UPnPSerial != tc.expectedUPnPSerial {
|
||||
t.Errorf("%s: Expected UPnPSerial '%s', got '%s'",
|
||||
tc.description, tc.expectedUPnPSerial, device.UPnPSerial)
|
||||
} else {
|
||||
t.Logf("✓ %s: Successfully extracted '%s'", tc.description, device.UPnPSerial)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUPnP_URLPattern_Realistic(t *testing.T) {
|
||||
// Test the exact URL pattern mentioned:
|
||||
// http://192.168.1.100:8091/XD/BO5EBO5E-F00D-F00D-FEED-A81B6A536A98.xml
|
||||
|
||||
realDeviceXML := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
|
||||
<friendlyName>Sound Machinechen</friendlyName>
|
||||
<manufacturer>Bose Corporation</manufacturer>
|
||||
<modelName>SoundTouch 10</modelName>
|
||||
<serialNumber>A81B6A536A98</serialNumber>
|
||||
<UDN>uuid:BO5EBO5E-F00D-F00D-FEED-A81B6A536A98</UDN>
|
||||
</device>
|
||||
</root>`
|
||||
|
||||
// Create server that responds to the specific path
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/XD/BO5EBO5E-F00D-F00D-FEED-A81B6A536A98.xml" {
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
fmt.Fprint(w, realDeviceXML)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Test enrichment using the realistic URL path
|
||||
device := &models.DiscoveredDevice{
|
||||
Host: "192.168.1.100",
|
||||
Port: 8091,
|
||||
Name: "Initial Name",
|
||||
}
|
||||
|
||||
service := NewService(5 * time.Second)
|
||||
locationURL := server.URL + "/XD/BO5EBO5E-F00D-F00D-FEED-A81B6A536A98.xml"
|
||||
err := service.EnrichDeviceInfo(device, locationURL)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("enrichDeviceInfo failed for realistic URL: %v", err)
|
||||
}
|
||||
|
||||
// Verify MAC address extraction
|
||||
expectedMAC := "A81B6A536A98"
|
||||
if device.UPnPSerial != expectedMAC {
|
||||
t.Errorf("Expected MAC '%s', got '%s'", expectedMAC, device.UPnPSerial)
|
||||
}
|
||||
|
||||
// Note: The MAC address in the URL and in the XML serialNumber should match
|
||||
if device.UPnPSerial == expectedMAC {
|
||||
t.Logf("✓ MAC address '%s' extracted from UPnP XML matches expected value", device.UPnPSerial)
|
||||
t.Logf("✓ This MAC can now be used for datastore mapping")
|
||||
t.Logf("✓ Request URL pattern: GET /streaming/account/{account}/device/%s/presets", device.UPnPSerial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUPnP_ErrorHandling(t *testing.T) {
|
||||
service := NewService(5 * time.Second)
|
||||
device := &models.DiscoveredDevice{
|
||||
Host: "192.168.1.100",
|
||||
Name: "Test Device",
|
||||
}
|
||||
|
||||
t.Run("InvalidXML", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
fmt.Fprint(w, "invalid xml content")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := service.EnrichDeviceInfo(device, server.URL)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid XML, got nil")
|
||||
} else {
|
||||
t.Logf("✓ Correctly handled invalid XML: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HTTPError", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := service.EnrichDeviceInfo(device, server.URL)
|
||||
if err == nil {
|
||||
t.Error("Expected error for HTTP 500, got nil")
|
||||
} else {
|
||||
t.Logf("✓ Correctly handled HTTP error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MissingSerialNumber", func(t *testing.T) {
|
||||
xmlWithoutSerial := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<device>
|
||||
<friendlyName>Test Device</friendlyName>
|
||||
<modelName>Test Model</modelName>
|
||||
</device>
|
||||
</root>`
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
fmt.Fprint(w, xmlWithoutSerial)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
deviceCopy := *device // Make a copy to avoid modifying the original
|
||||
err := service.EnrichDeviceInfo(&deviceCopy, server.URL)
|
||||
|
||||
// Should not error, but UPnPSerial should be empty
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for missing serialNumber: %v", err)
|
||||
}
|
||||
|
||||
if deviceCopy.UPnPSerial != "" {
|
||||
t.Errorf("Expected empty UPnPSerial, got '%s'", deviceCopy.UPnPSerial)
|
||||
} else {
|
||||
t.Logf("✓ Correctly handled missing serialNumber (empty UPnPSerial)")
|
||||
}
|
||||
})
|
||||
}
|
||||
+12
-23
@@ -2,9 +2,6 @@ package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -112,30 +109,18 @@ func TestParseLocationURL_Invalid(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseResponse_ValidMediaRenderer(t *testing.T) {
|
||||
xmlPayload := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<device>
|
||||
<friendlyName>Test Device</friendlyName>
|
||||
<modelName>SoundTouch 10</modelName>
|
||||
<serialNumber>AABBCCDDEEFF</serialNumber>
|
||||
</device>
|
||||
</root>`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
fmt.Fprint(w, xmlPayload)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := NewService(1 * time.Second)
|
||||
service.httpClient = server.Client()
|
||||
|
||||
validResponse := fmt.Sprintf(`HTTP/1.1 200 OK
|
||||
validResponse := `HTTP/1.1 200 OK
|
||||
Cache-Control: max-age=1800
|
||||
Location: %s
|
||||
Date: Mon, 22 Jun 1998 09:55:21 GMT
|
||||
EXT:
|
||||
Location: http://192.168.1.100:8090/device.xml
|
||||
Server: Linux/3.14.0 UPnP/1.0 Bose-SoundTouch/1.0
|
||||
ST: urn:schemas-upnp-org:device:MediaRenderer:1
|
||||
USN: uuid:12345678-1234-5678-9012-123456789012::urn:schemas-upnp-org:device:MediaRenderer:1
|
||||
|
||||
`, server.URL)
|
||||
`
|
||||
|
||||
device, err := service.parseResponse(validResponse)
|
||||
if err != nil {
|
||||
@@ -146,8 +131,12 @@ USN: uuid:12345678-1234-5678-9012-123456789012::urn:schemas-upnp-org:device:Medi
|
||||
t.Fatal("Expected device, got nil")
|
||||
}
|
||||
|
||||
if device.Name != "Test Device" {
|
||||
t.Errorf("Expected name 'Test Device', got '%s'", device.Name)
|
||||
if device.Host != "192.168.1.100" {
|
||||
t.Errorf("Expected host '192.168.1.100', got '%s'", device.Host)
|
||||
}
|
||||
|
||||
if device.UPnPLocation != "http://192.168.1.100:8090/device.xml" {
|
||||
t.Errorf("Expected UPnP location 'http://192.168.1.100:8090/device.xml', got '%s'", device.UPnPLocation)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,6 @@ type DiscoveredDevice struct {
|
||||
// Protocol-specific details
|
||||
UPnPLocation string `json:"upnp_location,omitempty"` // UPnP device description XML URL
|
||||
UPnPUSN string `json:"upnp_usn,omitempty"` // UPnP Unique Service Name
|
||||
UPnPSerial string `json:"upnp_serial,omitempty"` // Serial number from UPnP (MAC address)
|
||||
MDNSHostname string `json:"mdns_hostname,omitempty"` // mDNS hostname (e.g., "device.local.")
|
||||
MDNSService string `json:"mdns_service,omitempty"` // mDNS service name
|
||||
ConfigName string `json:"config_name,omitempty"` // Original name from config
|
||||
@@ -95,7 +94,6 @@ func (d *DiscoveredDevice) GetProtocolSpecificData() map[string]interface{} {
|
||||
data["upnp"] = map[string]string{
|
||||
"location": d.UPnPLocation,
|
||||
"usn": d.UPnPUSN,
|
||||
"serial": d.UPnPSerial,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -184,7 +184,6 @@ type ServiceDeviceInfo struct {
|
||||
FirmwareVersion string `json:"firmware_version" xml:"softwareVersion"`
|
||||
IPAddress string `json:"ip_address" xml:"ipAddress"`
|
||||
Name string `json:"name" xml:"name"`
|
||||
MacAddress string `json:"mac_address,omitempty" xml:"-"`
|
||||
DiscoveryMethod string `json:"discovery_method,omitempty"`
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ func TestCertificateManager(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test certificate regeneration if domains change
|
||||
newDomains := append(domains, "foo.local")
|
||||
newDomains := append(domains, "mac.fritz.box")
|
||||
tlsConfig2, err := cm.GetServerTLSConfig(newDomains)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get updated TLS config: %v", err)
|
||||
@@ -116,7 +116,7 @@ func TestCertificateManager(t *testing.T) {
|
||||
cert, _ := x509.ParseCertificate(block.Bytes)
|
||||
found := false
|
||||
for _, d := range cert.DNSNames {
|
||||
if d == "foo.local" {
|
||||
if d == "mac.fritz.box" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
)
|
||||
|
||||
func TestMacAddressCaseSensitivity(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "case-sensitivity-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
accountID := "3230304"
|
||||
serialNumber := "I6332527703739342000020"
|
||||
|
||||
// Test scenarios that could occur in production
|
||||
testCases := []struct {
|
||||
name string
|
||||
macInDeviceInfo string
|
||||
macInRequest string
|
||||
expectedToWork bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "ExactMatch",
|
||||
macInDeviceInfo: "A81B6A536A98",
|
||||
macInRequest: "A81B6A536A98",
|
||||
expectedToWork: true,
|
||||
description: "Exact case match should work",
|
||||
},
|
||||
{
|
||||
name: "DeviceInfoUpperRequestLower",
|
||||
macInDeviceInfo: "A81B6A536A98",
|
||||
macInRequest: "a81b6a536a98",
|
||||
expectedToWork: true,
|
||||
description: "DeviceInfo has uppercase, request has lowercase (should work with normalization)",
|
||||
},
|
||||
{
|
||||
name: "DeviceInfoLowerRequestUpper",
|
||||
macInDeviceInfo: "a81b6a536a98",
|
||||
macInRequest: "A81B6A536A98",
|
||||
expectedToWork: true,
|
||||
description: "DeviceInfo has lowercase, request has uppercase (should work with normalization)",
|
||||
},
|
||||
{
|
||||
name: "MixedCaseInDeviceInfo",
|
||||
macInDeviceInfo: "a81B6a536A98",
|
||||
macInRequest: "A81B6A536A98",
|
||||
expectedToWork: true,
|
||||
description: "Mixed case in DeviceInfo vs uppercase request (should work with normalization)",
|
||||
},
|
||||
{
|
||||
name: "WithColonsInDeviceInfo",
|
||||
macInDeviceInfo: "A8:1B:6A:53:6A:98",
|
||||
macInRequest: "A81B6A536A98",
|
||||
expectedToWork: true,
|
||||
description: "DeviceInfo has colons, request without (should work with normalization)",
|
||||
},
|
||||
{
|
||||
name: "WithDashesInDeviceInfo",
|
||||
macInDeviceInfo: "A8-1B-6A-53-6A-98",
|
||||
macInRequest: "A81B6A536A98",
|
||||
expectedToWork: true,
|
||||
description: "DeviceInfo has dashes, request without (should work with normalization)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create separate directory for this test case
|
||||
testDir := filepath.Join(tmpDir, tc.name)
|
||||
deviceDir := filepath.Join(testDir, "accounts", accountID, "devices", serialNumber)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create device dir: %v", err)
|
||||
}
|
||||
|
||||
// Create DeviceInfo.xml with specific MAC format
|
||||
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="` + serialNumber + `">
|
||||
<name>Test Device</name>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>4.8.1</softwareVersion>
|
||||
<serialNumber>` + serialNumber + `</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>` + tc.macInDeviceInfo + `</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
</info>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, constants.DeviceInfoFile), []byte(deviceInfoXML), 0644); err != nil {
|
||||
t.Fatalf("failed to write DeviceInfo.xml: %v", err)
|
||||
}
|
||||
|
||||
// Create Presets.xml
|
||||
presetsXML := `<presets><preset id="1">test</preset></presets>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, constants.PresetsFile), []byte(presetsXML), 0644); err != nil {
|
||||
t.Fatalf("failed to write Presets.xml: %v", err)
|
||||
}
|
||||
|
||||
// Initialize datastore
|
||||
ds := NewDataStore(testDir)
|
||||
if err := ds.Initialize(); err != nil {
|
||||
t.Fatalf("failed to initialize datastore: %v", err)
|
||||
}
|
||||
|
||||
// Check mapping
|
||||
ds.idMutex.RLock()
|
||||
mappedSerial, hasMappingForRequest := ds.deviceMappings[tc.macInRequest]
|
||||
mappedSerialFromDeviceInfo, hasMappingForDeviceInfo := ds.deviceMappings[tc.macInDeviceInfo]
|
||||
ds.idMutex.RUnlock()
|
||||
|
||||
t.Logf("%s:", tc.description)
|
||||
t.Logf(" MAC in DeviceInfo.xml: '%s'", tc.macInDeviceInfo)
|
||||
t.Logf(" MAC in request: '%s'", tc.macInRequest)
|
||||
t.Logf(" Mapping exists for request MAC: %v", hasMappingForRequest)
|
||||
t.Logf(" Mapping exists for DeviceInfo MAC: %v", hasMappingForDeviceInfo)
|
||||
|
||||
if hasMappingForRequest {
|
||||
t.Logf(" Request MAC '%s' maps to serial: '%s'", tc.macInRequest, mappedSerial)
|
||||
}
|
||||
if hasMappingForDeviceInfo {
|
||||
t.Logf(" DeviceInfo MAC '%s' maps to serial: '%s'", tc.macInDeviceInfo, mappedSerialFromDeviceInfo)
|
||||
}
|
||||
|
||||
// Try GetPresets
|
||||
_, err := ds.GetPresets(accountID, tc.macInRequest)
|
||||
worked := err == nil
|
||||
|
||||
if tc.expectedToWork && !worked {
|
||||
t.Errorf("Expected success but got error: %v", err)
|
||||
} else if !tc.expectedToWork && worked {
|
||||
t.Errorf("Expected failure but got success")
|
||||
} else if worked {
|
||||
t.Logf(" ✓ Successfully resolved MAC '%s'", tc.macInRequest)
|
||||
} else {
|
||||
t.Logf(" ✓ Correctly failed to resolve MAC '%s'", tc.macInRequest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProductionScenarioSimulation simulates the exact issue described
|
||||
func TestProductionScenarioSimulation(t *testing.T) {
|
||||
// This test specifically simulates the production scenario where:
|
||||
// Request: GET /streaming/account/3230304/device/A81B6A536A98/presets
|
||||
// File exists at: /var/lib/soundtouch-service/accounts/3230304/devices/I6332527703739342000020/Presets.xml
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "production-scenario")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
accountID := "3230304"
|
||||
serialNumber := "I6332527703739342000020"
|
||||
requestMAC := "A81B6A536A98"
|
||||
|
||||
// Test different MAC address formats that could be in DeviceInfo.xml
|
||||
possibleMACFormats := []string{
|
||||
"A81B6A536A98", // Exact match
|
||||
"a81b6a536a98", // All lowercase
|
||||
"A81b6a536A98", // Mixed case
|
||||
"A8:1B:6A:53:6A:98", // With colons
|
||||
"A8-1B-6A-53-6A-98", // With dashes
|
||||
"a8:1b:6a:53:6a:98", // Lowercase with colons
|
||||
"a8-1b-6a-53-6a-98", // Lowercase with dashes
|
||||
}
|
||||
|
||||
t.Logf("Production scenario simulation:")
|
||||
t.Logf("Request URL: GET /streaming/account/%s/device/%s/presets", accountID, requestMAC)
|
||||
t.Logf("Expected file location: accounts/%s/devices/%s/Presets.xml", accountID, serialNumber)
|
||||
t.Logf("")
|
||||
|
||||
for i, macFormat := range possibleMACFormats {
|
||||
t.Run(fmt.Sprintf("MACFormat_%d", i), func(t *testing.T) {
|
||||
// Create fresh directory for this test
|
||||
testDir := filepath.Join(tmpDir, fmt.Sprintf("test_%d", i))
|
||||
deviceDir := filepath.Join(testDir, "accounts", accountID, "devices", serialNumber)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create device dir: %v", err)
|
||||
}
|
||||
|
||||
// Create DeviceInfo.xml with this MAC format
|
||||
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="` + serialNumber + `">
|
||||
<name>SoundTouch Device</name>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>4.8.1</softwareVersion>
|
||||
<serialNumber>` + serialNumber + `</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>` + macFormat + `</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
</info>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, constants.DeviceInfoFile), []byte(deviceInfoXML), 0644); err != nil {
|
||||
t.Fatalf("failed to write DeviceInfo.xml: %v", err)
|
||||
}
|
||||
|
||||
// Create the target file that should be found
|
||||
presetsXML := `<presets><preset id="1">test</preset></presets>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, constants.PresetsFile), []byte(presetsXML), 0644); err != nil {
|
||||
t.Fatalf("failed to write Presets.xml: %v", err)
|
||||
}
|
||||
|
||||
// Initialize datastore
|
||||
ds := NewDataStore(testDir)
|
||||
if err := ds.Initialize(); err != nil {
|
||||
t.Fatalf("failed to initialize datastore: %v", err)
|
||||
}
|
||||
|
||||
// Try to access using the request MAC
|
||||
presets, err := ds.GetPresets(accountID, requestMAC)
|
||||
|
||||
if err == nil {
|
||||
t.Logf("✓ SUCCESS: MAC format '%s' in DeviceInfo allows request with '%s' to work (%d presets found)",
|
||||
macFormat, requestMAC, len(presets))
|
||||
} else {
|
||||
t.Logf("✗ FAILED: MAC format '%s' in DeviceInfo does not allow request with '%s' (error: %v)",
|
||||
macFormat, requestMAC, err)
|
||||
}
|
||||
|
||||
// Check what actually got mapped
|
||||
ds.idMutex.RLock()
|
||||
for mac, serial := range ds.deviceMappings {
|
||||
t.Logf(" Mapping: '%s' -> '%s'", mac, serial)
|
||||
}
|
||||
ds.idMutex.RUnlock()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizationSuggestion tests if we should implement MAC address normalization
|
||||
func TestNormalizationSuggestion(t *testing.T) {
|
||||
// This test demonstrates how MAC address normalization could solve the issue
|
||||
|
||||
normalizeMAC := func(mac string) string {
|
||||
// Remove common separators and convert to uppercase
|
||||
mac = strings.ReplaceAll(mac, ":", "")
|
||||
mac = strings.ReplaceAll(mac, "-", "")
|
||||
mac = strings.ToUpper(mac)
|
||||
return mac
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
original string
|
||||
normalized string
|
||||
}{
|
||||
{"A81B6A536A98", "A81B6A536A98"},
|
||||
{"a81b6a536a98", "A81B6A536A98"},
|
||||
{"A8:1B:6A:53:6A:98", "A81B6A536A98"},
|
||||
{"a8:1b:6a:53:6a:98", "A81B6A536A98"},
|
||||
{"A8-1B-6A-53-6A-98", "A81B6A536A98"},
|
||||
{"a8-1b-6a-53-6a-98", "A81B6A536A98"},
|
||||
{"a81B6a536A98", "A81B6A536A98"},
|
||||
}
|
||||
|
||||
t.Log("MAC Address Normalization Test:")
|
||||
t.Log("This shows how normalization could solve case/format sensitivity issues")
|
||||
t.Log("")
|
||||
|
||||
allNormalizedSame := true
|
||||
expectedNormalized := "A81B6A536A98"
|
||||
|
||||
for _, tc := range testCases {
|
||||
normalized := normalizeMAC(tc.original)
|
||||
matches := normalized == expectedNormalized
|
||||
|
||||
if !matches {
|
||||
allNormalizedSame = false
|
||||
}
|
||||
|
||||
t.Logf("'%s' -> '%s' (matches expected: %v)", tc.original, normalized, matches)
|
||||
}
|
||||
|
||||
if allNormalizedSame {
|
||||
t.Log("")
|
||||
t.Log("✓ All MAC address formats normalize to the same value")
|
||||
t.Log("✓ Implementing normalization would solve case/format sensitivity issues")
|
||||
} else {
|
||||
t.Error("✗ Normalization failed to produce consistent results")
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -24,26 +23,9 @@ func exists(path string) bool {
|
||||
|
||||
// DataStore represents the device and configuration storage.
|
||||
type DataStore struct {
|
||||
DataDir string
|
||||
eventMutex sync.RWMutex
|
||||
deviceEvents map[string][]models.DeviceEvent
|
||||
idMutex sync.RWMutex
|
||||
deviceMappings map[string]string
|
||||
}
|
||||
|
||||
// normalizeMAC normalizes a MAC address to a consistent format
|
||||
func normalizeMAC(mac string) string {
|
||||
if mac == "" {
|
||||
return ""
|
||||
}
|
||||
// Remove spaces and common separators, then convert to uppercase
|
||||
mac = strings.TrimSpace(mac)
|
||||
mac = strings.ReplaceAll(mac, " ", "")
|
||||
mac = strings.ReplaceAll(mac, ":", "")
|
||||
mac = strings.ReplaceAll(mac, "-", "")
|
||||
mac = strings.ToUpper(mac)
|
||||
|
||||
return mac
|
||||
DataDir string
|
||||
eventMutex sync.RWMutex
|
||||
deviceEvents map[string][]models.DeviceEvent
|
||||
}
|
||||
|
||||
// NewDataStore creates a new DataStore.
|
||||
@@ -54,9 +36,8 @@ func NewDataStore(dataDir string) *DataStore {
|
||||
}
|
||||
|
||||
return &DataStore{
|
||||
DataDir: dataDir,
|
||||
deviceEvents: make(map[string][]models.DeviceEvent),
|
||||
deviceMappings: make(map[string]string),
|
||||
DataDir: dataDir,
|
||||
deviceEvents: make(map[string][]models.DeviceEvent),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,37 +53,7 @@ func (ds *DataStore) AccountDevicesDir(account string) string {
|
||||
|
||||
// AccountDeviceDir returns the directory path for a specific device within an account.
|
||||
func (ds *DataStore) AccountDeviceDir(account, device string) string {
|
||||
// First, check if the device directory exists directly with the given deviceID
|
||||
// This prioritizes MAC-based deviceIDs over legacy mappings
|
||||
directPath := filepath.Join(ds.AccountDevicesDir(account), device)
|
||||
if _, err := os.Stat(directPath); err == nil {
|
||||
// Directory exists, use the direct deviceID (preferred for MAC-based IDs)
|
||||
return directPath
|
||||
}
|
||||
|
||||
// If direct path doesn't exist, check device mappings for backward compatibility
|
||||
ds.idMutex.RLock()
|
||||
|
||||
mappedDevice, ok := ds.deviceMappings[device]
|
||||
if !ok {
|
||||
// Try with normalized MAC address
|
||||
normalizedDevice := normalizeMAC(device)
|
||||
mappedDevice, ok = ds.deviceMappings[normalizedDevice]
|
||||
}
|
||||
|
||||
ds.idMutex.RUnlock()
|
||||
|
||||
if ok {
|
||||
// Use the mapped device only if it exists and the direct path doesn't
|
||||
mappedPath := filepath.Join(ds.AccountDevicesDir(account), mappedDevice)
|
||||
if _, err := os.Stat(mappedPath); err == nil {
|
||||
return mappedPath
|
||||
}
|
||||
}
|
||||
|
||||
// If neither direct path nor mapping work, return the direct path
|
||||
// (this allows new devices to be created with MAC-based IDs)
|
||||
return directPath
|
||||
return filepath.Join(ds.AccountDevicesDir(account), device)
|
||||
}
|
||||
|
||||
// GetDeviceInfo retrieves device information for the specified account and device.
|
||||
@@ -126,11 +77,9 @@ func (ds *DataStore) GetDeviceInfo(account, device string) (*models.ServiceDevic
|
||||
SerialNumber string `xml:"serialNumber"`
|
||||
} `xml:"components>component"`
|
||||
NetworkInfo []struct {
|
||||
Type string `xml:"type,attr"`
|
||||
IPAddress string `xml:"ipAddress"`
|
||||
MacAddress string `xml:"macAddress"`
|
||||
Type string `xml:"type,attr"`
|
||||
IPAddress string `xml:"ipAddress"`
|
||||
} `xml:"networkInfo"`
|
||||
DiscoveryMethod string `xml:"discoveryMethod"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(data, &info); err != nil {
|
||||
@@ -138,11 +87,9 @@ func (ds *DataStore) GetDeviceInfo(account, device string) (*models.ServiceDevic
|
||||
}
|
||||
|
||||
deviceInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: info.DeviceID,
|
||||
AccountID: account, // Set AccountID from parameter
|
||||
ProductCode: fmt.Sprintf("%s %s", info.Type, info.ModuleType),
|
||||
Name: info.Name,
|
||||
DiscoveryMethod: info.DiscoveryMethod,
|
||||
DeviceID: info.DeviceID,
|
||||
ProductCode: fmt.Sprintf("%s %s", info.Type, info.ModuleType),
|
||||
Name: info.Name,
|
||||
}
|
||||
|
||||
for _, comp := range info.Components {
|
||||
@@ -158,7 +105,6 @@ func (ds *DataStore) GetDeviceInfo(account, device string) (*models.ServiceDevic
|
||||
for _, net := range info.NetworkInfo {
|
||||
if net.Type == "SCM" {
|
||||
deviceInfo.IPAddress = net.IPAddress
|
||||
deviceInfo.MacAddress = net.MacAddress
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,9 +195,6 @@ func (ds *DataStore) listDevicesInAccount(baseDir, accountName string) []models.
|
||||
}
|
||||
|
||||
if err == nil && info != nil {
|
||||
// Update bidirectional device mappings for resolution
|
||||
ds.updateDeviceMappings(*info)
|
||||
|
||||
devices = append(devices, *info)
|
||||
}
|
||||
}
|
||||
@@ -277,9 +220,8 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
|
||||
SerialNumber string `xml:"serialNumber"`
|
||||
} `xml:"components>component"`
|
||||
NetworkInfo []struct {
|
||||
Type string `xml:"type,attr"`
|
||||
IPAddress string `xml:"ipAddress"`
|
||||
MacAddress string `xml:"macAddress"`
|
||||
Type string `xml:"type,attr"`
|
||||
IPAddress string `xml:"ipAddress"`
|
||||
} `xml:"networkInfo"`
|
||||
DiscoveryMethod string `xml:"discoveryMethod"`
|
||||
}
|
||||
@@ -308,7 +250,6 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
|
||||
for _, net := range info.NetworkInfo {
|
||||
if net.Type == "SCM" {
|
||||
deviceInfo.IPAddress = net.IPAddress
|
||||
deviceInfo.MacAddress = net.MacAddress
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,9 +495,8 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
|
||||
}
|
||||
|
||||
type NetworkInfoXML struct {
|
||||
Type string `xml:"type,attr"`
|
||||
IPAddress string `xml:"ipAddress"`
|
||||
MacAddress string `xml:"macAddress"`
|
||||
Type string `xml:"type,attr"`
|
||||
IPAddress string `xml:"ipAddress"`
|
||||
}
|
||||
|
||||
type InfoXML struct {
|
||||
@@ -602,9 +542,8 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
|
||||
},
|
||||
NetworkInfo: []NetworkInfoXML{
|
||||
{
|
||||
Type: "SCM",
|
||||
IPAddress: info.IPAddress,
|
||||
MacAddress: info.MacAddress,
|
||||
Type: "SCM",
|
||||
IPAddress: info.IPAddress,
|
||||
},
|
||||
},
|
||||
DiscoveryMethod: info.DiscoveryMethod,
|
||||
@@ -626,11 +565,6 @@ func (ds *DataStore) RemoveDevice(account, device string) error {
|
||||
return os.RemoveAll(dir)
|
||||
}
|
||||
|
||||
// RemoveDeviceDir is an alias for RemoveDevice for backwards compatibility.
|
||||
func (ds *DataStore) RemoveDeviceDir(account, device string) error {
|
||||
return ds.RemoveDevice(account, device)
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -699,102 +633,14 @@ func (ds *DataStore) SaveConfiguredSources(account, device string, sources []mod
|
||||
return os.WriteFile(path, append(header, data...), 0644)
|
||||
}
|
||||
|
||||
// updateDeviceMappings creates bidirectional mappings for device resolution
|
||||
func (ds *DataStore) updateDeviceMappings(info models.ServiceDeviceInfo) {
|
||||
ds.idMutex.Lock()
|
||||
defer ds.idMutex.Unlock()
|
||||
|
||||
deviceID := info.DeviceID
|
||||
macAddress := info.MacAddress
|
||||
deviceSerial := info.DeviceSerialNumber
|
||||
|
||||
// If device is stored with MAC as deviceID and has a serial, create backward mapping
|
||||
if isMACAddressFormat(deviceID) && deviceSerial != "" && deviceSerial != deviceID {
|
||||
ds.deviceMappings[deviceSerial] = deviceID
|
||||
}
|
||||
|
||||
// If device is stored with serial as deviceID and has a MAC, create forward mapping
|
||||
if !isMACAddressFormat(deviceID) && macAddress != "" {
|
||||
ds.deviceMappings[macAddress] = deviceID
|
||||
// Also store normalized MAC version
|
||||
normalizedMAC := normalizeMAC(macAddress)
|
||||
if normalizedMAC != macAddress {
|
||||
ds.deviceMappings[normalizedMAC] = deviceID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateMapping maintains backward compatibility for external callers
|
||||
func (ds *DataStore) UpdateMapping(mac, serial string) {
|
||||
if mac == "" || serial == "" {
|
||||
return
|
||||
}
|
||||
|
||||
ds.idMutex.Lock()
|
||||
defer ds.idMutex.Unlock()
|
||||
|
||||
// In the new system, MAC addresses are preferred as deviceIDs
|
||||
// So map the serial TO the MAC (reverse of old system)
|
||||
ds.deviceMappings[serial] = mac
|
||||
|
||||
// Also map MAC to serial for any remaining legacy code
|
||||
ds.deviceMappings[mac] = serial
|
||||
|
||||
normalizedMAC := normalizeMAC(mac)
|
||||
if normalizedMAC != mac {
|
||||
ds.deviceMappings[normalizedMAC] = serial
|
||||
}
|
||||
}
|
||||
|
||||
// isMACAddressFormat checks if a string looks like a MAC address
|
||||
func isMACAddressFormat(s string) bool {
|
||||
// AABBCCDDEEFF format
|
||||
if len(s) == 12 {
|
||||
return isHexOnly(s)
|
||||
}
|
||||
|
||||
// AA:BB:CC:DD:EE:FF or AA-BB-CC-DD-EE-FF format
|
||||
if len(s) == 17 && (strings.Contains(s, ":") || strings.Contains(s, "-")) {
|
||||
s = strings.ReplaceAll(s, "-", ":")
|
||||
|
||||
parts := strings.Split(s, ":")
|
||||
if len(parts) != 6 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, part := range parts {
|
||||
if len(part) != 2 || !isHexOnly(part) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isHexOnly(s string) bool {
|
||||
for _, r := range s {
|
||||
if (r < '0' || r > '9') && (r < 'A' || r > 'F') && (r < 'a' || r > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Initialize creates the necessary directory structure for the datastore and populates ID mappings.
|
||||
// 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 {
|
||||
return fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
// Scan for devices to populate MAC to Serial mapping
|
||||
_, err := ds.ListAllDevices()
|
||||
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetETagForPresets returns the ETag (modification time) for the presets file for a specific device.
|
||||
@@ -863,11 +709,8 @@ type Settings struct {
|
||||
DiscoveryEnabled bool `json:"discovery_enabled"`
|
||||
EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"`
|
||||
DNSEnabled bool `json:"dns_enabled"`
|
||||
DNSUpstream []string `json:"dns_upstream,omitempty"`
|
||||
DNSUpstream string `json:"dns_upstream,omitempty"`
|
||||
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
|
||||
MirrorEnabled bool `json:"mirror_enabled"`
|
||||
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
|
||||
InternalPaths []string `json:"internal_paths,omitempty"`
|
||||
Shortcuts map[string]int `json:"shortcuts,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ func TestListAllDevices(t *testing.T) {
|
||||
info := &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
Name: "Test Speaker",
|
||||
IPAddress: "192.168.1.100",
|
||||
IPAddress: "192.168.178.28",
|
||||
DeviceSerialNumber: deviceID,
|
||||
ProductCode: "SoundTouch 10",
|
||||
FirmwareVersion: "1.2.3",
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestMacAddressSerialization(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "mac-serialization-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
account := "3230304"
|
||||
device := "I6332527703739342000020"
|
||||
macAddress := "A81B6A536A98"
|
||||
|
||||
// Create device info with MAC address
|
||||
info := &models.ServiceDeviceInfo{
|
||||
DeviceID: device,
|
||||
Name: "Test SoundTouch",
|
||||
ProductCode: "SoundTouch 10",
|
||||
IPAddress: "192.168.1.100",
|
||||
MacAddress: macAddress,
|
||||
DeviceSerialNumber: device,
|
||||
ProductSerialNumber: "PROD123456",
|
||||
FirmwareVersion: "4.8.1.23456",
|
||||
DiscoveryMethod: "UPnP",
|
||||
}
|
||||
|
||||
// Save device info
|
||||
err = ds.SaveDeviceInfo(account, device, info)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveDeviceInfo failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the XML file was created
|
||||
deviceInfoPath := filepath.Join(ds.AccountDeviceDir(account, device), "DeviceInfo.xml")
|
||||
if _, err := os.Stat(deviceInfoPath); err != nil {
|
||||
t.Fatalf("DeviceInfo.xml not created: %v", err)
|
||||
}
|
||||
|
||||
// Read back the device info
|
||||
loadedInfo, err := ds.GetDeviceInfo(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDeviceInfo failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify MAC address is preserved
|
||||
if loadedInfo.MacAddress != macAddress {
|
||||
t.Errorf("MAC address not preserved. Expected: '%s', Got: '%s'", macAddress, loadedInfo.MacAddress)
|
||||
}
|
||||
|
||||
// Verify other fields are also correct
|
||||
if loadedInfo.DeviceID != device {
|
||||
t.Errorf("DeviceID mismatch. Expected: %s, Got: %s", device, loadedInfo.DeviceID)
|
||||
}
|
||||
|
||||
if loadedInfo.IPAddress != "192.168.1.100" {
|
||||
t.Errorf("IPAddress mismatch. Expected: 192.168.1.100, Got: %s", loadedInfo.IPAddress)
|
||||
}
|
||||
|
||||
// Initialize datastore to populate MAC mappings
|
||||
err = ds.Initialize()
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize failed: %v", err)
|
||||
}
|
||||
|
||||
// Test that MAC address mapping works
|
||||
resolvedPath := ds.AccountDeviceDir(account, macAddress)
|
||||
expectedPath := ds.AccountDeviceDir(account, device)
|
||||
|
||||
if resolvedPath != expectedPath {
|
||||
t.Errorf("MAC address mapping failed. MAC '%s' resolved to '%s', expected '%s'",
|
||||
macAddress, resolvedPath, expectedPath)
|
||||
}
|
||||
|
||||
// Test that Sources.xml path resolves correctly via MAC address
|
||||
// (We don't need to actually read the file, just verify the path resolution works)
|
||||
macPath := ds.AccountDeviceDir(account, macAddress)
|
||||
devicePath := ds.AccountDeviceDir(account, device)
|
||||
|
||||
if macPath != devicePath {
|
||||
t.Errorf("MAC address path resolution failed. MAC path: %s, Device path: %s", macPath, devicePath)
|
||||
}
|
||||
|
||||
t.Logf("✅ MAC address serialization working correctly")
|
||||
t.Logf(" - MAC address '%s' saved to DeviceInfo.xml", macAddress)
|
||||
t.Logf(" - MAC address '%s' loaded from DeviceInfo.xml", loadedInfo.MacAddress)
|
||||
t.Logf(" - MAC mapping: '%s' -> '%s'", macAddress, device)
|
||||
t.Logf(" - Sources.xml accessible via MAC address")
|
||||
}
|
||||
|
||||
func TestMacAddressSerializationEdgeCases(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "mac-edge-cases-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
account := "testaccount"
|
||||
device := "testdevice"
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
macAddress string
|
||||
expected string
|
||||
}{
|
||||
{"uppercase", "A81B6A536A98", "A81B6A536A98"},
|
||||
{"lowercase", "a81b6a536a98", "a81b6a536a98"},
|
||||
{"with_colons", "A8:1B:6A:53:6A:98", "A8:1B:6A:53:6A:98"},
|
||||
{"with_dashes", "A8-1B-6A-53-6A-98", "A8-1B-6A-53-6A-98"},
|
||||
{"empty", "", ""},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
deviceID := device + "_" + tc.name
|
||||
|
||||
info := &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
Name: "Test Device " + tc.name,
|
||||
ProductCode: "SoundTouch 10",
|
||||
IPAddress: "192.168.1.100",
|
||||
MacAddress: tc.macAddress,
|
||||
DeviceSerialNumber: deviceID,
|
||||
}
|
||||
|
||||
// Save and load
|
||||
err := ds.SaveDeviceInfo(account, deviceID, info)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveDeviceInfo failed for %s: %v", tc.name, err)
|
||||
}
|
||||
|
||||
loadedInfo, err := ds.GetDeviceInfo(account, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDeviceInfo failed for %s: %v", tc.name, err)
|
||||
}
|
||||
|
||||
if loadedInfo.MacAddress != tc.expected {
|
||||
t.Errorf("MAC address mismatch for %s. Expected: '%s', Got: '%s'",
|
||||
tc.name, tc.expected, loadedInfo.MacAddress)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingDeviceInfoUpdate(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "device-update-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
account := "3230304"
|
||||
device := "I6332527703739342000020"
|
||||
|
||||
// First save without MAC address (simulating old DeviceInfo.xml)
|
||||
infoWithoutMAC := &models.ServiceDeviceInfo{
|
||||
DeviceID: device,
|
||||
Name: "Test SoundTouch",
|
||||
ProductCode: "SoundTouch 10",
|
||||
IPAddress: "192.168.1.100",
|
||||
MacAddress: "", // No MAC address initially
|
||||
DeviceSerialNumber: device,
|
||||
}
|
||||
|
||||
err = ds.SaveDeviceInfo(account, device, infoWithoutMAC)
|
||||
if err != nil {
|
||||
t.Fatalf("Initial SaveDeviceInfo failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify no MAC address initially
|
||||
loadedInfo1, err := ds.GetDeviceInfo(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("Initial GetDeviceInfo failed: %v", err)
|
||||
}
|
||||
|
||||
if loadedInfo1.MacAddress != "" {
|
||||
t.Errorf("Expected empty MAC address, got '%s'", loadedInfo1.MacAddress)
|
||||
}
|
||||
|
||||
// Now update with MAC address (simulating discovery update)
|
||||
macAddress := "A81B6A536A98"
|
||||
infoWithMAC := &models.ServiceDeviceInfo{
|
||||
DeviceID: device,
|
||||
Name: "Test SoundTouch",
|
||||
ProductCode: "SoundTouch 10",
|
||||
IPAddress: "192.168.1.100",
|
||||
MacAddress: macAddress,
|
||||
DeviceSerialNumber: device,
|
||||
}
|
||||
|
||||
err = ds.SaveDeviceInfo(account, device, infoWithMAC)
|
||||
if err != nil {
|
||||
t.Fatalf("Update SaveDeviceInfo failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify MAC address is now present
|
||||
loadedInfo2, err := ds.GetDeviceInfo(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("Updated GetDeviceInfo failed: %v", err)
|
||||
}
|
||||
|
||||
if loadedInfo2.MacAddress != macAddress {
|
||||
t.Errorf("MAC address not updated. Expected: '%s', Got: '%s'", macAddress, loadedInfo2.MacAddress)
|
||||
}
|
||||
|
||||
// Initialize to test mapping
|
||||
err = ds.Initialize()
|
||||
if err != nil {
|
||||
t.Fatalf("Initialize failed: %v", err)
|
||||
}
|
||||
|
||||
// Test that MAC mapping now works
|
||||
resolvedPath := ds.AccountDeviceDir(account, macAddress)
|
||||
expectedPath := ds.AccountDeviceDir(account, device)
|
||||
|
||||
if resolvedPath != expectedPath {
|
||||
t.Errorf("MAC mapping failed after update. MAC '%s' resolved to '%s', expected '%s'",
|
||||
macAddress, resolvedPath, expectedPath)
|
||||
}
|
||||
|
||||
t.Logf("✅ DeviceInfo.xml update with MAC address working correctly")
|
||||
t.Logf(" - Initial: no MAC address")
|
||||
t.Logf(" - Updated: MAC address '%s' added", macAddress)
|
||||
t.Logf(" - Mapping: '%s' -> '%s'", macAddress, device)
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestAccountDeviceDir_MACFirstResolution(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "mac-first-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
accountID := "testaccount"
|
||||
macAddress := "A81B6A536A98"
|
||||
serialNumber := "I6332527703739342000020"
|
||||
|
||||
t.Run("NewMACBasedDevice", func(t *testing.T) {
|
||||
// Create a new device with MAC as deviceID
|
||||
deviceInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: macAddress,
|
||||
AccountID: accountID,
|
||||
Name: "New MAC Device",
|
||||
IPAddress: "192.168.1.100",
|
||||
MacAddress: macAddress,
|
||||
DeviceSerialNumber: serialNumber,
|
||||
ProductCode: "SoundTouch 10 sm2",
|
||||
}
|
||||
|
||||
// Save the device
|
||||
if err := ds.SaveDeviceInfo(accountID, macAddress, deviceInfo); err != nil {
|
||||
t.Fatalf("Failed to save MAC-based device: %v", err)
|
||||
}
|
||||
|
||||
// Test AccountDeviceDir resolution
|
||||
resolvedDir := ds.AccountDeviceDir(accountID, macAddress)
|
||||
expectedDir := filepath.Join(tempDir, "accounts", accountID, "devices", macAddress)
|
||||
|
||||
if resolvedDir != expectedDir {
|
||||
t.Errorf("Expected MAC-based device dir '%s', got '%s'", expectedDir, resolvedDir)
|
||||
}
|
||||
|
||||
// Verify the directory actually exists
|
||||
if _, err := os.Stat(resolvedDir); os.IsNotExist(err) {
|
||||
t.Errorf("MAC-based device directory should exist: %s", resolvedDir)
|
||||
}
|
||||
|
||||
t.Logf("✅ MAC-based device correctly resolved to: %s", resolvedDir)
|
||||
})
|
||||
|
||||
t.Run("LegacySerialBasedDevice", func(t *testing.T) {
|
||||
// Create a legacy device with serial as deviceID (simulating old storage)
|
||||
legacyInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: serialNumber,
|
||||
AccountID: accountID,
|
||||
Name: "Legacy Serial Device",
|
||||
IPAddress: "192.168.1.101",
|
||||
MacAddress: macAddress,
|
||||
DeviceSerialNumber: serialNumber,
|
||||
ProductCode: "SoundTouch 10",
|
||||
}
|
||||
|
||||
// Save the legacy device
|
||||
if err := ds.SaveDeviceInfo(accountID, serialNumber, legacyInfo); err != nil {
|
||||
t.Fatalf("Failed to save legacy device: %v", err)
|
||||
}
|
||||
|
||||
// Initialize to populate mappings
|
||||
if err := ds.Initialize(); err != nil {
|
||||
t.Fatalf("Failed to initialize datastore: %v", err)
|
||||
}
|
||||
|
||||
// Test resolution by MAC address (should find the legacy device via mapping)
|
||||
resolvedDir := ds.AccountDeviceDir(accountID, macAddress)
|
||||
expectedLegacyDir := filepath.Join(tempDir, "accounts", accountID, "devices", serialNumber)
|
||||
|
||||
// Since both MAC and serial devices exist, MAC device should take priority
|
||||
expectedMACDir := filepath.Join(tempDir, "accounts", accountID, "devices", macAddress)
|
||||
if resolvedDir != expectedMACDir {
|
||||
t.Errorf("Expected MAC device to take priority. Got '%s', expected '%s'", resolvedDir, expectedMACDir)
|
||||
}
|
||||
|
||||
t.Logf("✅ MAC address resolution correctly prioritized MAC-based device")
|
||||
|
||||
// Test resolution by serial number (should find the legacy device directly)
|
||||
serialResolvedDir := ds.AccountDeviceDir(accountID, serialNumber)
|
||||
if serialResolvedDir != expectedLegacyDir {
|
||||
t.Errorf("Expected serial-based device dir '%s', got '%s'", expectedLegacyDir, serialResolvedDir)
|
||||
}
|
||||
|
||||
t.Logf("✅ Serial number correctly resolved to legacy device: %s", serialResolvedDir)
|
||||
})
|
||||
|
||||
t.Run("MACResolutionWithOnlyLegacyDevice", func(t *testing.T) {
|
||||
// Create a fresh datastore
|
||||
tempDir2, err := os.MkdirTemp("", "mac-legacy-only-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir2)
|
||||
|
||||
ds2 := NewDataStore(tempDir2)
|
||||
testAccount := "legacyaccount"
|
||||
testSerial := "LEGACY123456789"
|
||||
testMAC := "BB:CC:DD:EE:FF:00"
|
||||
|
||||
// Create ONLY a legacy device (no MAC-based device)
|
||||
legacyInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: testSerial,
|
||||
AccountID: testAccount,
|
||||
Name: "Only Legacy Device",
|
||||
MacAddress: testMAC,
|
||||
DeviceSerialNumber: testSerial,
|
||||
}
|
||||
|
||||
if err := ds2.SaveDeviceInfo(testAccount, testSerial, legacyInfo); err != nil {
|
||||
t.Fatalf("Failed to save legacy-only device: %v", err)
|
||||
}
|
||||
|
||||
// Initialize to populate mappings
|
||||
if err := ds2.Initialize(); err != nil {
|
||||
t.Fatalf("Failed to initialize datastore: %v", err)
|
||||
}
|
||||
|
||||
// Test MAC resolution (should find the legacy device via mapping)
|
||||
resolvedDir := ds2.AccountDeviceDir(testAccount, testMAC)
|
||||
expectedDir := filepath.Join(tempDir2, "accounts", testAccount, "devices", testSerial)
|
||||
|
||||
if resolvedDir != expectedDir {
|
||||
t.Errorf("MAC '%s' should resolve to legacy device '%s', got '%s'", testMAC, expectedDir, resolvedDir)
|
||||
}
|
||||
|
||||
t.Logf("✅ MAC address correctly resolved to legacy device when no MAC-based device exists")
|
||||
})
|
||||
|
||||
t.Run("NonExistentDevice", func(t *testing.T) {
|
||||
unknownMAC := "FF:FF:FF:FF:FF:FF"
|
||||
resolvedDir := ds.AccountDeviceDir(accountID, unknownMAC)
|
||||
expectedDir := filepath.Join(tempDir, "accounts", accountID, "devices", unknownMAC)
|
||||
|
||||
if resolvedDir != expectedDir {
|
||||
t.Errorf("Non-existent device should resolve to direct path '%s', got '%s'", expectedDir, resolvedDir)
|
||||
}
|
||||
|
||||
t.Logf("✅ Non-existent device correctly resolved to direct MAC path")
|
||||
})
|
||||
|
||||
t.Run("MACNormalization", func(t *testing.T) {
|
||||
// Test different MAC address formats
|
||||
macFormats := []string{
|
||||
"A81B6A536A98", // No separators
|
||||
"A8:1B:6A:53:6A:98", // Colons
|
||||
"A8-1B-6A-53-6A-98", // Dashes
|
||||
"a81b6a536a98", // Lowercase
|
||||
"a8:1b:6a:53:6a:98", // Lowercase with colons
|
||||
}
|
||||
|
||||
for _, macFormat := range macFormats {
|
||||
resolvedDir := ds.AccountDeviceDir(accountID, macFormat)
|
||||
// Should resolve to the MAC-based device we created earlier
|
||||
expectedDir := filepath.Join(tempDir, "accounts", accountID, "devices", macAddress)
|
||||
|
||||
if resolvedDir != expectedDir {
|
||||
t.Logf("MAC format '%s' resolved to '%s', expected '%s'", macFormat, resolvedDir, expectedDir)
|
||||
// For now, we'll log this - full normalization might require additional work
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("BackwardCompatibilityMapping", func(t *testing.T) {
|
||||
// Test that the legacy UpdateMapping method still works
|
||||
testMAC := "CC:DD:EE:FF:00:11"
|
||||
testSerial := "COMPAT789"
|
||||
|
||||
ds.UpdateMapping(testMAC, testSerial)
|
||||
|
||||
// After calling UpdateMapping, the MAC should resolve via the mapping
|
||||
resolvedDir := ds.AccountDeviceDir(accountID, testMAC)
|
||||
directPath := filepath.Join(tempDir, "accounts", accountID, "devices", testMAC)
|
||||
|
||||
// Since no actual device exists, it should return the direct path
|
||||
if resolvedDir != directPath {
|
||||
t.Errorf("UpdateMapping backward compatibility test failed. Got '%s', expected '%s'", resolvedDir, directPath)
|
||||
}
|
||||
|
||||
t.Logf("✅ UpdateMapping backward compatibility maintained")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeviceMappings_Bidirectional(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "bidirectional-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
accountID := "testaccount"
|
||||
|
||||
t.Run("MACBasedDeviceCreatesSerialMapping", func(t *testing.T) {
|
||||
macAddress := "11:22:33:44:55:66"
|
||||
serialNumber := "NEWDEVICE123"
|
||||
|
||||
// Create MAC-based device
|
||||
deviceInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: macAddress,
|
||||
AccountID: accountID,
|
||||
Name: "MAC First Device",
|
||||
MacAddress: macAddress,
|
||||
DeviceSerialNumber: serialNumber,
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(accountID, macAddress, deviceInfo); err != nil {
|
||||
t.Fatalf("Failed to save MAC-based device: %v", err)
|
||||
}
|
||||
|
||||
// Initialize to populate mappings
|
||||
if err := ds.Initialize(); err != nil {
|
||||
t.Fatalf("Failed to initialize: %v", err)
|
||||
}
|
||||
|
||||
// Serial should resolve to the MAC-based device
|
||||
resolvedDir := ds.AccountDeviceDir(accountID, serialNumber)
|
||||
expectedDir := filepath.Join(tempDir, "accounts", accountID, "devices", macAddress)
|
||||
|
||||
if resolvedDir != expectedDir {
|
||||
t.Errorf("Serial '%s' should resolve to MAC device '%s', got '%s'", serialNumber, expectedDir, resolvedDir)
|
||||
}
|
||||
|
||||
t.Logf("✅ MAC-based device creates correct serial→MAC mapping")
|
||||
})
|
||||
|
||||
t.Run("SerialBasedDeviceCreatesMACMapping", func(t *testing.T) {
|
||||
macAddress := "77:88:99:AA:BB:CC"
|
||||
serialNumber := "SERIALDEVICE456"
|
||||
|
||||
// Create serial-based device (legacy)
|
||||
deviceInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: serialNumber,
|
||||
AccountID: accountID,
|
||||
Name: "Serial First Device",
|
||||
MacAddress: macAddress,
|
||||
DeviceSerialNumber: serialNumber,
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(accountID, serialNumber, deviceInfo); err != nil {
|
||||
t.Fatalf("Failed to save serial-based device: %v", err)
|
||||
}
|
||||
|
||||
// Initialize to populate mappings
|
||||
if err := ds.Initialize(); err != nil {
|
||||
t.Fatalf("Failed to initialize: %v", err)
|
||||
}
|
||||
|
||||
// MAC should resolve to the serial-based device
|
||||
resolvedDir := ds.AccountDeviceDir(accountID, macAddress)
|
||||
expectedDir := filepath.Join(tempDir, "accounts", accountID, "devices", serialNumber)
|
||||
|
||||
if resolvedDir != expectedDir {
|
||||
t.Errorf("MAC '%s' should resolve to serial device '%s', got '%s'", macAddress, expectedDir, resolvedDir)
|
||||
}
|
||||
|
||||
t.Logf("✅ Serial-based device creates correct MAC→serial mapping")
|
||||
})
|
||||
}
|
||||
|
||||
func TestMACAddressFormatDetection(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input string
|
||||
expected bool
|
||||
name string
|
||||
}{
|
||||
{"A81B6A536A98", true, "12-char hex"},
|
||||
{"a81b6a536a98", true, "12-char hex lowercase"},
|
||||
{"A8:1B:6A:53:6A:98", true, "colon-separated"},
|
||||
{"A8-1B-6A-53-6A-98", true, "dash-separated"},
|
||||
{"a8:1b:6a:53:6a:98", true, "colon-separated lowercase"},
|
||||
{"a8-1b-6a-53-6a-98", true, "dash-separated lowercase"},
|
||||
{"I6332527703739342000020", false, "device serial"},
|
||||
{"192.168.1.100", false, "IP address"},
|
||||
{"ABCDEFGHIJKL", false, "12-char non-hex"},
|
||||
{"A8:1B:6A:53:6A", false, "incomplete MAC"},
|
||||
{"A8:1B:6A:53:6A:98:01", false, "too long MAC"},
|
||||
{"", false, "empty string"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := isMACAddressFormat(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("isMACAddressFormat('%s') = %v, expected %v", tc.input, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountDeviceDir_PriorityOrder(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "priority-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
accountID := "prioritytest"
|
||||
macAddress := "A8:1B:6A:53:6A:98"
|
||||
serialNumber := "PRIORITY123456789"
|
||||
|
||||
// Create both MAC-based and serial-based devices for the same physical device
|
||||
macDevice := &models.ServiceDeviceInfo{
|
||||
DeviceID: macAddress,
|
||||
AccountID: accountID,
|
||||
Name: "MAC Version",
|
||||
MacAddress: macAddress,
|
||||
DeviceSerialNumber: serialNumber,
|
||||
}
|
||||
|
||||
serialDevice := &models.ServiceDeviceInfo{
|
||||
DeviceID: serialNumber,
|
||||
AccountID: accountID,
|
||||
Name: "Serial Version",
|
||||
MacAddress: macAddress,
|
||||
DeviceSerialNumber: serialNumber,
|
||||
}
|
||||
|
||||
// Save both devices
|
||||
if err := ds.SaveDeviceInfo(accountID, macAddress, macDevice); err != nil {
|
||||
t.Fatalf("Failed to save MAC device: %v", err)
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(accountID, serialNumber, serialDevice); err != nil {
|
||||
t.Fatalf("Failed to save serial device: %v", err)
|
||||
}
|
||||
|
||||
// Initialize mappings
|
||||
if err := ds.Initialize(); err != nil {
|
||||
t.Fatalf("Failed to initialize: %v", err)
|
||||
}
|
||||
|
||||
// Test priority: MAC address should resolve to MAC-based device (not serial-based)
|
||||
resolvedDir := ds.AccountDeviceDir(accountID, macAddress)
|
||||
expectedMACDir := filepath.Join(tempDir, "accounts", accountID, "devices", macAddress)
|
||||
|
||||
if resolvedDir != expectedMACDir {
|
||||
t.Errorf("MAC address should resolve to MAC-based device directory")
|
||||
t.Errorf("Expected: %s", expectedMACDir)
|
||||
t.Errorf("Got: %s", resolvedDir)
|
||||
}
|
||||
|
||||
// Test that serial still resolves to its own device
|
||||
serialResolvedDir := ds.AccountDeviceDir(accountID, serialNumber)
|
||||
expectedSerialDir := filepath.Join(tempDir, "accounts", accountID, "devices", serialNumber)
|
||||
|
||||
if serialResolvedDir != expectedSerialDir {
|
||||
t.Errorf("Serial should resolve to serial-based device directory")
|
||||
t.Errorf("Expected: %s", expectedSerialDir)
|
||||
t.Errorf("Got: %s", serialResolvedDir)
|
||||
}
|
||||
|
||||
t.Logf("✅ Priority test passed:")
|
||||
t.Logf(" MAC '%s' → %s", macAddress, resolvedDir)
|
||||
t.Logf(" Serial '%s' → %s", serialNumber, serialResolvedDir)
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
)
|
||||
|
||||
func TestMacMappingDiagnostic(t *testing.T) {
|
||||
// Test the exact scenario described in the issue
|
||||
tmpDir, err := os.MkdirTemp("", "mac-mapping-diagnostic")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
accountID := "3230304"
|
||||
serialNumber := "I6332527703739342000020"
|
||||
macAddress := "A81B6A536A98"
|
||||
|
||||
// Create the directory structure as it exists in production
|
||||
deviceDir := filepath.Join(tmpDir, "accounts", accountID, "devices", serialNumber)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create device dir: %v", err)
|
||||
}
|
||||
|
||||
// Create DeviceInfo.xml with the MAC address
|
||||
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="` + serialNumber + `">
|
||||
<name>SoundTouch Device</name>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>4.8.1</softwareVersion>
|
||||
<serialNumber>` + serialNumber + `</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>` + macAddress + `</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
</info>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, constants.DeviceInfoFile), []byte(deviceInfoXML), 0644); err != nil {
|
||||
t.Fatalf("failed to write DeviceInfo.xml: %v", err)
|
||||
}
|
||||
|
||||
// Create Presets.xml to simulate the file that should be found
|
||||
presetsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets>
|
||||
<preset id="1">
|
||||
<ContentItem source="SPOTIFY" type="station" location="/station/abc123" sourceAccount="spotify_user">
|
||||
<itemName>My Preset</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, constants.PresetsFile), []byte(presetsXML), 0644); err != nil {
|
||||
t.Fatalf("failed to write Presets.xml: %v", err)
|
||||
}
|
||||
|
||||
// Initialize the datastore
|
||||
ds := NewDataStore(tmpDir)
|
||||
if err := ds.Initialize(); err != nil {
|
||||
t.Fatalf("failed to initialize datastore: %v", err)
|
||||
}
|
||||
|
||||
// Test 1: Check if the mapping was populated
|
||||
t.Run("CheckMappingPopulation", func(t *testing.T) {
|
||||
ds.idMutex.RLock()
|
||||
serial, ok := ds.deviceMappings[macAddress]
|
||||
ds.idMutex.RUnlock()
|
||||
|
||||
if !ok {
|
||||
t.Errorf("MAC address %s not found in mapping", macAddress)
|
||||
} else if serial != serialNumber {
|
||||
t.Errorf("MAC address %s mapped to %s, expected %s", macAddress, serial, serialNumber)
|
||||
} else {
|
||||
t.Logf("✓ MAC address %s correctly mapped to %s", macAddress, serial)
|
||||
}
|
||||
})
|
||||
|
||||
// Test 2: Check AccountDeviceDir resolution
|
||||
t.Run("CheckAccountDeviceDir", func(t *testing.T) {
|
||||
// Test with MAC address (should resolve to serial)
|
||||
resolvedDir := ds.AccountDeviceDir(accountID, macAddress)
|
||||
expectedDir := filepath.Join(tmpDir, "accounts", accountID, "devices", serialNumber)
|
||||
|
||||
if resolvedDir != expectedDir {
|
||||
t.Errorf("AccountDeviceDir with MAC %s resolved to %s, expected %s", macAddress, resolvedDir, expectedDir)
|
||||
} else {
|
||||
t.Logf("✓ AccountDeviceDir correctly resolved MAC %s to path %s", macAddress, resolvedDir)
|
||||
}
|
||||
|
||||
// Test with serial number (should work as-is)
|
||||
resolvedDirSerial := ds.AccountDeviceDir(accountID, serialNumber)
|
||||
if resolvedDirSerial != expectedDir {
|
||||
t.Errorf("AccountDeviceDir with serial %s resolved to %s, expected %s", serialNumber, resolvedDirSerial, expectedDir)
|
||||
} else {
|
||||
t.Logf("✓ AccountDeviceDir works correctly with serial number %s", serialNumber)
|
||||
}
|
||||
})
|
||||
|
||||
// Test 3: Check GetPresets functionality with MAC address
|
||||
t.Run("CheckGetPresetsWithMAC", func(t *testing.T) {
|
||||
presets, err := ds.GetPresets(accountID, macAddress)
|
||||
if err != nil {
|
||||
t.Errorf("GetPresets failed with MAC address %s: %v", macAddress, err)
|
||||
} else if len(presets) == 0 {
|
||||
t.Error("GetPresets returned no presets")
|
||||
} else {
|
||||
t.Logf("✓ GetPresets successfully returned %d presets using MAC address %s", len(presets), macAddress)
|
||||
}
|
||||
})
|
||||
|
||||
// Test 4: Check GetPresets functionality with serial number
|
||||
t.Run("CheckGetPresetsWithSerial", func(t *testing.T) {
|
||||
presets, err := ds.GetPresets(accountID, serialNumber)
|
||||
if err != nil {
|
||||
t.Errorf("GetPresets failed with serial number %s: %v", serialNumber, err)
|
||||
} else if len(presets) == 0 {
|
||||
t.Error("GetPresets returned no presets")
|
||||
} else {
|
||||
t.Logf("✓ GetPresets successfully returned %d presets using serial number %s", len(presets), serialNumber)
|
||||
}
|
||||
})
|
||||
|
||||
// Test 5: Check case sensitivity
|
||||
t.Run("CheckCaseSensitivity", func(t *testing.T) {
|
||||
lowercaseMAC := "a81b6a536a98"
|
||||
uppercaseMAC := "A81B6A536A98"
|
||||
|
||||
ds.idMutex.RLock()
|
||||
_, lowercaseOk := ds.deviceMappings[lowercaseMAC]
|
||||
_, uppercaseOk := ds.deviceMappings[uppercaseMAC]
|
||||
ds.idMutex.RUnlock()
|
||||
|
||||
t.Logf("Lowercase MAC '%s' in mapping: %v", lowercaseMAC, lowercaseOk)
|
||||
t.Logf("Uppercase MAC '%s' in mapping: %v", uppercaseMAC, uppercaseOk)
|
||||
|
||||
// Test GetPresets with different cases
|
||||
_, errLower := ds.GetPresets(accountID, lowercaseMAC)
|
||||
_, errUpper := ds.GetPresets(accountID, uppercaseMAC)
|
||||
|
||||
t.Logf("GetPresets with lowercase MAC error: %v", errLower)
|
||||
t.Logf("GetPresets with uppercase MAC error: %v", errUpper)
|
||||
})
|
||||
|
||||
// Test 6: Dump all mappings for debugging
|
||||
t.Run("DumpMappings", func(t *testing.T) {
|
||||
ds.idMutex.RLock()
|
||||
defer ds.idMutex.RUnlock()
|
||||
|
||||
t.Logf("Total mappings found: %d", len(ds.deviceMappings))
|
||||
for mac, serial := range ds.deviceMappings {
|
||||
t.Logf(" MAC '%s' -> Serial '%s'", mac, serial)
|
||||
}
|
||||
})
|
||||
|
||||
// Test 7: Check actual file paths
|
||||
t.Run("CheckFilePaths", func(t *testing.T) {
|
||||
// Path that should work (with serial number)
|
||||
correctPath := filepath.Join(tmpDir, "accounts", accountID, "devices", serialNumber, constants.PresetsFile)
|
||||
if _, err := os.Stat(correctPath); err != nil {
|
||||
t.Errorf("File not found at correct path %s: %v", correctPath, err)
|
||||
} else {
|
||||
t.Logf("✓ File found at correct path: %s", correctPath)
|
||||
}
|
||||
|
||||
// Path that would be wrong (with MAC address)
|
||||
wrongPath := filepath.Join(tmpDir, "accounts", accountID, "devices", macAddress, constants.PresetsFile)
|
||||
if _, err := os.Stat(wrongPath); err == nil {
|
||||
t.Logf("⚠️ File also found at MAC path (unexpected): %s", wrongPath)
|
||||
} else {
|
||||
t.Logf("✓ File correctly not found at MAC path: %s", wrongPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestMacMappingWithDifferentFormats tests various MAC address formats
|
||||
func TestMacMappingWithDifferentFormats(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "mac-format-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
macInXML string
|
||||
macInRequest string
|
||||
shouldWork bool
|
||||
}{
|
||||
{"ExactMatch", "A81B6A536A98", "A81B6A536A98", true},
|
||||
{"LowerCase", "A81B6A536A98", "a81b6a536a98", true}, // Should work with normalization
|
||||
{"UpperCase", "a81b6a536a98", "A81B6A536A98", true}, // Should work with normalization
|
||||
{"WithColons", "A8:1B:6A:53:6A:98", "A81B6A536A98", true}, // Should work with normalization
|
||||
{"WithDashes", "A8-1B-6A-53-6A-98", "A81B6A536A98", true}, // Should work with normalization
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create separate directory for each test case
|
||||
testDir := filepath.Join(tmpDir, tc.name)
|
||||
accountID := "12345"
|
||||
serialNumber := "TEST123456789"
|
||||
|
||||
deviceDir := filepath.Join(testDir, "accounts", accountID, "devices", serialNumber)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create device dir: %v", err)
|
||||
}
|
||||
|
||||
// Create DeviceInfo.xml with the specific MAC format
|
||||
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="` + serialNumber + `">
|
||||
<name>Test Device</name>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>4.8.1</softwareVersion>
|
||||
<serialNumber>` + serialNumber + `</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>` + tc.macInXML + `</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
</info>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, constants.DeviceInfoFile), []byte(deviceInfoXML), 0644); err != nil {
|
||||
t.Fatalf("failed to write DeviceInfo.xml: %v", err)
|
||||
}
|
||||
|
||||
// Create Presets.xml
|
||||
presetsXML := `<presets><preset id="1">test</preset></presets>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, constants.PresetsFile), []byte(presetsXML), 0644); err != nil {
|
||||
t.Fatalf("failed to write Presets.xml: %v", err)
|
||||
}
|
||||
|
||||
// Initialize datastore
|
||||
ds := NewDataStore(testDir)
|
||||
if err := ds.Initialize(); err != nil {
|
||||
t.Fatalf("failed to initialize datastore: %v", err)
|
||||
}
|
||||
|
||||
// Try to get presets using the request MAC format
|
||||
_, err := ds.GetPresets(accountID, tc.macInRequest)
|
||||
|
||||
if tc.shouldWork && err != nil {
|
||||
t.Errorf("Expected success but got error: %v", err)
|
||||
} else if !tc.shouldWork && err == nil {
|
||||
t.Errorf("Expected failure but got success")
|
||||
} else if tc.shouldWork {
|
||||
t.Logf("✓ Successfully resolved MAC '%s' to serial '%s' (normalization worked)", tc.macInRequest, serialNumber)
|
||||
} else {
|
||||
t.Logf("✓ Correctly failed to resolve MAC '%s' (XML had '%s')", tc.macInRequest, tc.macInXML)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
)
|
||||
|
||||
func TestDataStore_MacAddressMapping(t *testing.T) {
|
||||
if err := os.MkdirAll("testdata/mapping", 0755); err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll("testdata/mapping")
|
||||
|
||||
accountID := "12345"
|
||||
serialNumber := "SERIAL123"
|
||||
macAddress := "AABBCCDDEEFF"
|
||||
|
||||
// Create directory structure
|
||||
deviceDir := filepath.Join("testdata/mapping", "accounts", accountID, "devices", serialNumber)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create device dir: %v", err)
|
||||
}
|
||||
|
||||
// Create DeviceInfo.xml with MAC address
|
||||
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="` + serialNumber + `">
|
||||
<name>Test Device</name>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>1.0</softwareVersion>
|
||||
<serialNumber>` + serialNumber + `</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>` + macAddress + `</macAddress>
|
||||
<ipAddress>192.168.1.10</ipAddress>
|
||||
</networkInfo>
|
||||
</info>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, constants.DeviceInfoFile), []byte(deviceInfoXML), 0644); err != nil {
|
||||
t.Fatalf("failed to write DeviceInfo.xml: %v", err)
|
||||
}
|
||||
|
||||
// Create Presets.xml so we can verify access
|
||||
presetsXML := `<presets><preset id="1">test</preset></presets>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, constants.PresetsFile), []byte(presetsXML), 0644); err != nil {
|
||||
t.Fatalf("failed to write Presets.xml: %v", err)
|
||||
}
|
||||
|
||||
ds := NewDataStore("testdata/mapping")
|
||||
if err := ds.Initialize(); err != nil {
|
||||
t.Fatalf("failed to initialize datastore: %v", err)
|
||||
}
|
||||
|
||||
// Test mapping resolution in AccountDeviceDir
|
||||
resolvedDir := ds.AccountDeviceDir(accountID, macAddress)
|
||||
expectedDir := filepath.Join("testdata/mapping", "accounts", accountID, "devices", serialNumber)
|
||||
if resolvedDir != expectedDir {
|
||||
t.Errorf("expected dir %s, got %s", expectedDir, resolvedDir)
|
||||
}
|
||||
|
||||
// Test that we can still use the serial number directly
|
||||
resolvedDirSerial := ds.AccountDeviceDir(accountID, serialNumber)
|
||||
if resolvedDirSerial != expectedDir {
|
||||
t.Errorf("expected dir %s when using serial, got %s", expectedDir, resolvedDirSerial)
|
||||
}
|
||||
|
||||
// Test GetPresets using MAC address
|
||||
presets, err := ds.GetPresets(accountID, macAddress)
|
||||
if err != nil {
|
||||
t.Errorf("GetPresets failed with MAC address: %v", err)
|
||||
}
|
||||
if len(presets) == 0 {
|
||||
t.Error("expected presets to be loaded")
|
||||
}
|
||||
}
|
||||
@@ -1,386 +0,0 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
)
|
||||
|
||||
func TestUPnPDiscoveryToDatastoreMapping_FullFlow(t *testing.T) {
|
||||
// This test demonstrates the complete flow:
|
||||
// 1. UPnP discovery finds device with MAC in serialNumber
|
||||
// 2. Device is stored in datastore with serial number directory
|
||||
// 3. MAC address mapping is established
|
||||
// 4. HTTP requests using MAC address are resolved to correct directory
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "upnp-datastore-integration")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Test data matching the user's scenario
|
||||
accountID := "3230304"
|
||||
deviceSerial := "I6332527703739342000020"
|
||||
deviceMAC := "A81B6A536A98"
|
||||
deviceName := "Sound Machinechen"
|
||||
|
||||
t.Logf("Test scenario:")
|
||||
t.Logf(" Account: %s", accountID)
|
||||
t.Logf(" Device Serial: %s", deviceSerial)
|
||||
t.Logf(" Device MAC: %s", deviceMAC)
|
||||
t.Logf(" Expected directory: accounts/%s/devices/%s/", accountID, deviceSerial)
|
||||
t.Logf(" Expected request: GET /streaming/account/%s/device/%s/presets", accountID, deviceMAC)
|
||||
t.Logf("")
|
||||
|
||||
// Step 1: Create the device directory structure using serial number
|
||||
deviceDir := filepath.Join(tmpDir, "accounts", accountID, "devices", deviceSerial)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create device dir: %v", err)
|
||||
}
|
||||
|
||||
// Create DeviceInfo.xml with MAC address
|
||||
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="` + deviceSerial + `">
|
||||
<name>` + deviceName + `</name>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>4.8.1</softwareVersion>
|
||||
<serialNumber>` + deviceSerial + `</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>` + deviceMAC + `</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
</info>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, constants.DeviceInfoFile), []byte(deviceInfoXML), 0644); err != nil {
|
||||
t.Fatalf("failed to write DeviceInfo.xml: %v", err)
|
||||
}
|
||||
|
||||
// Create Presets.xml (the target file we want to access)
|
||||
presetsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets>
|
||||
<preset id="1">
|
||||
<ContentItem source="SPOTIFY" type="station" location="/station/test123" sourceAccount="spotify">
|
||||
<itemName>My Spotify Station</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<preset id="2">
|
||||
<ContentItem source="TUNEIN" type="station" location="/station/s12345" sourceAccount="">
|
||||
<itemName>NPR News</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, constants.PresetsFile), []byte(presetsXML), 0644); err != nil {
|
||||
t.Fatalf("failed to write Presets.xml: %v", err)
|
||||
}
|
||||
|
||||
// Step 2: Simulate UPnP discovery with real device XML
|
||||
t.Run("Step2_UPnPDiscovery", func(t *testing.T) {
|
||||
// UPnP XML exactly as provided by the user
|
||||
upnpXML := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<specVersion>
|
||||
<major>1</major>
|
||||
<minor>0</minor>
|
||||
</specVersion>
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
|
||||
<friendlyName>` + deviceName + `</friendlyName>
|
||||
<qq:X_QPlay_SoftwareCapability xmlns:qq="http://www.tencent.com">QPlay:2</qq:X_QPlay_SoftwareCapability>
|
||||
<manufacturer>Bose Corporation</manufacturer>
|
||||
<manufacturerURL>http://www.bose.com</manufacturerURL>
|
||||
<modelName>SoundTouch 10</modelName>
|
||||
<modelNumber></modelNumber>
|
||||
<modelDescription>Bose SoundTouch Wireless Streaming Audio Device</modelDescription>
|
||||
<modelURL>http://www.bose.com</modelURL>
|
||||
<serialNumber>` + deviceMAC + `</serialNumber>
|
||||
<UDN>uuid:BO5EBO5E-F00D-F00D-FEED-` + deviceMAC + `</UDN>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
|
||||
<serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
|
||||
<SCPDURL>/Xml/AVTransport3.xml</SCPDURL>
|
||||
<controlURL>/AVTransport/Control</controlURL>
|
||||
<eventSubURL>/AVTransport/Event</eventSubURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</root>`
|
||||
|
||||
// Create UPnP server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
|
||||
fmt.Fprint(w, upnpXML)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Simulate UPnP discovery
|
||||
discoveryService := discovery.NewService(5 * time.Second)
|
||||
device := &models.DiscoveredDevice{
|
||||
Host: "192.168.1.100",
|
||||
Port: 8091,
|
||||
Name: "Initial Name",
|
||||
}
|
||||
|
||||
err := discoveryService.EnrichDeviceInfo(device, server.URL+"/XD/BO5EBO5E-F00D-F00D-FEED-"+deviceMAC+".xml")
|
||||
if err != nil {
|
||||
t.Errorf("UPnP enrichment failed: %v", err)
|
||||
} else {
|
||||
t.Logf("✓ UPnP discovery extracted MAC: '%s' from serialNumber", device.UPnPSerial)
|
||||
}
|
||||
|
||||
// Verify UPnP extraction
|
||||
if device.UPnPSerial != deviceMAC {
|
||||
t.Errorf("Expected UPnPSerial '%s', got '%s'", deviceMAC, device.UPnPSerial)
|
||||
}
|
||||
})
|
||||
|
||||
// Step 3: Initialize datastore and verify mapping
|
||||
t.Run("Step3_DatastoreMapping", func(t *testing.T) {
|
||||
ds := NewDataStore(tmpDir)
|
||||
err := ds.Initialize()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize datastore: %v", err)
|
||||
}
|
||||
|
||||
// Verify mapping was created during initialization
|
||||
ds.idMutex.RLock()
|
||||
mappedSerial, hasMappingExact := ds.deviceMappings[deviceMAC]
|
||||
normalizedMAC := normalizeMAC(deviceMAC)
|
||||
mappedSerialNormalized, hasMappingNormalized := ds.deviceMappings[normalizedMAC]
|
||||
ds.idMutex.RUnlock()
|
||||
|
||||
t.Logf("Mapping check:")
|
||||
t.Logf(" Original MAC '%s' -> mapped: %v", deviceMAC, hasMappingExact)
|
||||
if hasMappingExact {
|
||||
t.Logf(" Original MAC maps to: '%s'", mappedSerial)
|
||||
}
|
||||
t.Logf(" Normalized MAC '%s' -> mapped: %v", normalizedMAC, hasMappingNormalized)
|
||||
if hasMappingNormalized {
|
||||
t.Logf(" Normalized MAC maps to: '%s'", mappedSerialNormalized)
|
||||
}
|
||||
|
||||
if !hasMappingExact && !hasMappingNormalized {
|
||||
t.Error("No mapping found for MAC address")
|
||||
} else {
|
||||
t.Logf("✓ MAC address mapping established successfully")
|
||||
}
|
||||
})
|
||||
|
||||
// Step 4: Test HTTP request resolution
|
||||
t.Run("Step4_HTTPRequestResolution", func(t *testing.T) {
|
||||
ds := NewDataStore(tmpDir)
|
||||
err := ds.Initialize()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize datastore: %v", err)
|
||||
}
|
||||
|
||||
// Test various MAC address formats in HTTP requests
|
||||
testCases := []struct {
|
||||
name string
|
||||
requestMAC string
|
||||
shouldWork bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "ExactMatch",
|
||||
requestMAC: "A81B6A536A98",
|
||||
shouldWork: true,
|
||||
description: "Exact MAC match",
|
||||
},
|
||||
{
|
||||
name: "LowercaseMAC",
|
||||
requestMAC: "a81b6a536a98",
|
||||
shouldWork: true,
|
||||
description: "Lowercase MAC (should work with normalization)",
|
||||
},
|
||||
{
|
||||
name: "MACWithColons",
|
||||
requestMAC: "A8:1B:6A:53:6A:98",
|
||||
shouldWork: true,
|
||||
description: "MAC with colons (should work with normalization)",
|
||||
},
|
||||
{
|
||||
name: "MACWithDashes",
|
||||
requestMAC: "A8-1B-6A-53-6A-98",
|
||||
shouldWork: true,
|
||||
description: "MAC with dashes (should work with normalization)",
|
||||
},
|
||||
{
|
||||
name: "InvalidMAC",
|
||||
requestMAC: "INVALID123456",
|
||||
shouldWork: false,
|
||||
description: "Invalid MAC (should fail)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Simulate HTTP request: GET /streaming/account/{account}/device/{device}/presets
|
||||
presets, err := ds.GetPresets(accountID, tc.requestMAC)
|
||||
|
||||
if tc.shouldWork {
|
||||
if err != nil {
|
||||
t.Errorf("%s failed: %v", tc.description, err)
|
||||
} else if len(presets) == 0 {
|
||||
t.Errorf("%s: no presets returned", tc.description)
|
||||
} else {
|
||||
t.Logf("✓ %s: Successfully retrieved %d presets", tc.description, len(presets))
|
||||
|
||||
// Verify preset content
|
||||
if presets[0].ID == "1" && presets[1].ID == "2" {
|
||||
t.Logf(" ✓ Preset content verified (IDs: %s, %s)", presets[0].ID, presets[1].ID)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err == nil {
|
||||
t.Errorf("%s: expected failure but got success", tc.description)
|
||||
} else {
|
||||
t.Logf("✓ %s: Correctly failed with error: %v", tc.description, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Step 5: Test directory resolution
|
||||
t.Run("Step5_DirectoryResolution", func(t *testing.T) {
|
||||
ds := NewDataStore(tmpDir)
|
||||
err := ds.Initialize()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to initialize datastore: %v", err)
|
||||
}
|
||||
|
||||
// Test AccountDeviceDir resolution
|
||||
resolvedDirMAC := ds.AccountDeviceDir(accountID, deviceMAC)
|
||||
resolvedDirSerial := ds.AccountDeviceDir(accountID, deviceSerial)
|
||||
expectedDir := filepath.Join(tmpDir, "accounts", accountID, "devices", deviceSerial)
|
||||
|
||||
t.Logf("Directory resolution:")
|
||||
t.Logf(" Request with MAC '%s' -> '%s'", deviceMAC, resolvedDirMAC)
|
||||
t.Logf(" Request with serial '%s' -> '%s'", deviceSerial, resolvedDirSerial)
|
||||
t.Logf(" Expected directory: '%s'", expectedDir)
|
||||
|
||||
if resolvedDirMAC != expectedDir {
|
||||
t.Errorf("MAC resolution failed: expected '%s', got '%s'", expectedDir, resolvedDirMAC)
|
||||
} else {
|
||||
t.Logf("✓ MAC address correctly resolved to serial number directory")
|
||||
}
|
||||
|
||||
if resolvedDirSerial != expectedDir {
|
||||
t.Errorf("Serial resolution failed: expected '%s', got '%s'", expectedDir, resolvedDirSerial)
|
||||
} else {
|
||||
t.Logf("✓ Serial number resolution works correctly")
|
||||
}
|
||||
})
|
||||
|
||||
// Step 6: Integration summary
|
||||
t.Run("Step6_IntegrationSummary", func(t *testing.T) {
|
||||
t.Log("")
|
||||
t.Log("=== INTEGRATION SUMMARY ===")
|
||||
t.Log("✅ UPnP Discovery: MAC address extracted from serialNumber field")
|
||||
t.Log("✅ Datastore Initialization: MAC-to-serial mapping created from DeviceInfo.xml")
|
||||
t.Log("✅ MAC Normalization: Case and format variations handled correctly")
|
||||
t.Log("✅ HTTP Request Resolution: MAC addresses resolve to correct device directories")
|
||||
t.Log("✅ File Access: Presets.xml found using MAC address in request URL")
|
||||
t.Log("")
|
||||
t.Log("The original issue has been resolved:")
|
||||
t.Logf(" Request: GET /streaming/account/%s/device/%s/presets", accountID, deviceMAC)
|
||||
t.Logf(" Resolves to: %s/accounts/%s/devices/%s/Presets.xml", tmpDir, accountID, deviceSerial)
|
||||
t.Log("")
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizationEdgeCases(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input string
|
||||
expected string
|
||||
desc string
|
||||
}{
|
||||
{"", "", "empty string"},
|
||||
{"a", "A", "single character"},
|
||||
{"ab", "AB", "two characters"},
|
||||
{"A81B6A536A98", "A81B6A536A98", "standard MAC"},
|
||||
{"a81b6a536a98", "A81B6A536A98", "lowercase MAC"},
|
||||
{"A8:1B:6A:53:6A:98", "A81B6A536A98", "MAC with colons"},
|
||||
{"A8-1B-6A-53-6A-98", "A81B6A536A98", "MAC with dashes"},
|
||||
{"a8:1b:6a:53:6a:98", "A81B6A536A98", "lowercase MAC with colons"},
|
||||
{"a8-1b-6a-53-6a-98", "A81B6A536A98", "lowercase MAC with dashes"},
|
||||
{"A8::1B::6A", "A81B6A", "multiple consecutive colons"},
|
||||
{"A8--1B--6A", "A81B6A", "multiple consecutive dashes"},
|
||||
{"A8:-1B-:6A", "A81B6A", "mixed separators"},
|
||||
{" A81B6A536A98 ", "A81B6A536A98", "MAC with spaces (handled by normalization)"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
result := normalizeMAC(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("normalizeMAC(%q) = %q, expected %q", tc.input, result, tc.expected)
|
||||
} else {
|
||||
t.Logf("✓ %s: %q -> %q", tc.desc, tc.input, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMACMappingPerformance(t *testing.T) {
|
||||
// Test performance with many mappings
|
||||
tmpDir, err := os.MkdirTemp("", "mac-performance-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
ds := NewDataStore(tmpDir)
|
||||
|
||||
// Add many mappings
|
||||
numMappings := 1000
|
||||
t.Logf("Testing performance with %d MAC mappings...", numMappings)
|
||||
|
||||
start := time.Now()
|
||||
for i := 0; i < numMappings; i++ {
|
||||
mac := fmt.Sprintf("AA:BB:CC:DD:EE:%02X", i%256)
|
||||
serial := fmt.Sprintf("SERIAL%06d", i)
|
||||
ds.UpdateMapping(mac, serial)
|
||||
}
|
||||
updateDuration := time.Since(start)
|
||||
|
||||
// Test lookup performance
|
||||
start = time.Now()
|
||||
for i := 0; i < numMappings; i++ {
|
||||
mac := fmt.Sprintf("AA:BB:CC:DD:EE:%02X", i%256)
|
||||
accountID := "test"
|
||||
_ = ds.AccountDeviceDir(accountID, mac)
|
||||
}
|
||||
lookupDuration := time.Since(start)
|
||||
|
||||
t.Logf("✓ Performance test completed:")
|
||||
t.Logf(" Update %d mappings: %v (%.2f μs per mapping)", numMappings, updateDuration, float64(updateDuration.Nanoseconds())/float64(numMappings)/1000.0)
|
||||
t.Logf(" Lookup %d mappings: %v (%.2f μs per lookup)", numMappings, lookupDuration, float64(lookupDuration.Nanoseconds())/float64(numMappings)/1000.0)
|
||||
|
||||
// Verify total mappings (should be more than numMappings due to normalization)
|
||||
ds.idMutex.RLock()
|
||||
totalMappings := len(ds.deviceMappings)
|
||||
ds.idMutex.RUnlock()
|
||||
|
||||
t.Logf(" Total mappings stored: %d (includes normalized versions)", totalMappings)
|
||||
|
||||
if updateDuration > time.Millisecond*100 {
|
||||
t.Errorf("Update performance too slow: %v", updateDuration)
|
||||
}
|
||||
if lookupDuration > time.Millisecond*70 {
|
||||
t.Errorf("Lookup performance too slow: %v", lookupDuration)
|
||||
}
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
)
|
||||
|
||||
func TestComprehensiveMigration_MultipleExistingDevices(t *testing.T) {
|
||||
// This test simulates the real-world scenario where a device has been discovered
|
||||
// and saved under multiple identifiers over time, and now needs to be consolidated
|
||||
// into a single MAC-based identifier.
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "comprehensive-migration-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
accountID := "3230304"
|
||||
|
||||
// Scenario: Same device has been saved under different identifiers:
|
||||
// 1. Initially discovered by IP address
|
||||
// 2. Later discovered with UPnP serial
|
||||
// 3. Later discovered with device component serial
|
||||
|
||||
// Create device entry #1: Saved by IP address (early discovery)
|
||||
ipDeviceID := "192.168.1.100"
|
||||
ipInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: ipDeviceID,
|
||||
AccountID: accountID,
|
||||
Name: "Unknown Device", // Generic name from early discovery
|
||||
IPAddress: ipDeviceID,
|
||||
ProductCode: "Unknown",
|
||||
FirmwareVersion: "0.0.0",
|
||||
DiscoveryMethod: "UPnP",
|
||||
}
|
||||
if err := ds.SaveDeviceInfo(accountID, ipDeviceID, ipInfo); err != nil {
|
||||
t.Fatalf("Failed to save IP-based device: %v", err)
|
||||
}
|
||||
|
||||
// Save some presets for the IP-based device
|
||||
testPresets := []models.ServicePreset{
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ID: "1",
|
||||
Source: "SPOTIFY",
|
||||
Location: "spotify://playlist/test1",
|
||||
Name: "Test Playlist 1",
|
||||
},
|
||||
CreatedOn: "2024-01-01T00:00:00Z",
|
||||
UpdatedOn: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
}
|
||||
if err := ds.SavePresets(accountID, ipDeviceID, testPresets); err != nil {
|
||||
t.Fatalf("Failed to save presets for IP device: %v", err)
|
||||
}
|
||||
|
||||
// Create device entry #2: Saved by component serial (later discovery with better info)
|
||||
serialDeviceID := "I6332527703739342000020"
|
||||
serialInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: serialDeviceID,
|
||||
AccountID: accountID,
|
||||
Name: "Sound Machinechen", // Real name from /info
|
||||
IPAddress: "192.168.1.100", // Same IP as before
|
||||
DeviceSerialNumber: serialDeviceID,
|
||||
ProductCode: "SoundTouch 10",
|
||||
FirmwareVersion: "27.0.6.46330.5043500",
|
||||
ProductSerialNumber: "069231P63364828AE",
|
||||
DiscoveryMethod: "UPnP",
|
||||
}
|
||||
if err := ds.SaveDeviceInfo(accountID, serialDeviceID, serialInfo); err != nil {
|
||||
t.Fatalf("Failed to save serial-based device: %v", err)
|
||||
}
|
||||
|
||||
// Save different presets for the serial-based device (user might have configured both thinking they're different)
|
||||
serialPresets := []models.ServicePreset{
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ID: "2",
|
||||
Source: "SPOTIFY",
|
||||
Location: "spotify://playlist/test2",
|
||||
Name: "Test Playlist 2",
|
||||
},
|
||||
CreatedOn: "2024-01-02T00:00:00Z",
|
||||
UpdatedOn: "2024-01-02T00:00:00Z",
|
||||
},
|
||||
}
|
||||
if err := ds.SavePresets(accountID, serialDeviceID, serialPresets); err != nil {
|
||||
t.Fatalf("Failed to save presets for serial device: %v", err)
|
||||
}
|
||||
|
||||
// Create device entry #3: Saved by UPnP serial (yet another discovery)
|
||||
upnpDeviceID := "UPnP789XYZ"
|
||||
upnpInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: upnpDeviceID,
|
||||
AccountID: accountID,
|
||||
Name: "SoundTouch Device", // Generic UPnP name
|
||||
IPAddress: "192.168.1.100", // Same IP again
|
||||
ProductCode: "SoundTouch 10 sm2",
|
||||
FirmwareVersion: "27.0.6.46330.5043500", // Same firmware as serial device
|
||||
DiscoveryMethod: "UPnP",
|
||||
}
|
||||
if err := ds.SaveDeviceInfo(accountID, upnpDeviceID, upnpInfo); err != nil {
|
||||
t.Fatalf("Failed to save UPnP-based device: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Test setup complete:")
|
||||
t.Logf(" Device #1: %s (IP-based, early discovery)", ipDeviceID)
|
||||
t.Logf(" Device #2: %s (serial-based, better info)", serialDeviceID)
|
||||
t.Logf(" Device #3: %s (UPnP-based, latest discovery)", upnpDeviceID)
|
||||
|
||||
// Now simulate the device being rediscovered with /info endpoint working
|
||||
deviceInfoXML := `<info deviceID="A81B6A536A98">
|
||||
<name>Sound Machinechen</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<margeAccountUUID>3230304</margeAccountUUID>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</softwareVersion>
|
||||
<serialNumber>I6332527703739342000020</serialNumber>
|
||||
</component>
|
||||
<component>
|
||||
<componentCategory>PackagedProduct</componentCategory>
|
||||
<softwareVersion>27.0.6.46330.5043500</softwareVersion>
|
||||
<serialNumber>069231P63364828AE</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<margeURL>https://streaming.bose.com</margeURL>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>A81B6A536A98</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
<moduleType>sm2</moduleType>
|
||||
</info>`
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/info" {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, deviceInfoXML)
|
||||
} else {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
deviceIP := server.URL[len("http://"):]
|
||||
sm := setup.NewManager(server.URL, ds, nil)
|
||||
srv := NewServer(ds, sm, server.URL, false, false, false, false, false, false)
|
||||
|
||||
// Simulate device rediscovery
|
||||
discoveredDevice := models.DiscoveredDevice{
|
||||
Host: deviceIP,
|
||||
Name: "Generic Discovery Name",
|
||||
ModelID: "SoundTouch",
|
||||
SerialNo: "UPnP789XYZ", // This should match one of the existing devices
|
||||
DiscoveryMethod: "UPnP",
|
||||
}
|
||||
|
||||
t.Logf("\nSimulating comprehensive device rediscovery...")
|
||||
t.Logf(" Discovery IP: %s", deviceIP)
|
||||
t.Logf(" Discovery Serial: %s", discoveredDevice.SerialNo)
|
||||
|
||||
// Handle discovered device - should find and migrate all existing variants
|
||||
srv.handleDiscoveredDevice(discoveredDevice)
|
||||
|
||||
// Verify the device now exists under the MAC address
|
||||
expectedDeviceID := "A81B6A536A98"
|
||||
migratedInfo, err := ds.GetDeviceInfo(accountID, expectedDeviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get migrated device info: %v", err)
|
||||
}
|
||||
|
||||
// Verify the migrated device has the correct information
|
||||
if migratedInfo.DeviceID != expectedDeviceID {
|
||||
t.Errorf("Expected deviceID '%s', got '%s'", expectedDeviceID, migratedInfo.DeviceID)
|
||||
}
|
||||
|
||||
if migratedInfo.Name != "Sound Machinechen" {
|
||||
t.Errorf("Expected name 'Sound Machinechen' (from /info), got '%s'", migratedInfo.Name)
|
||||
}
|
||||
|
||||
if migratedInfo.MacAddress != "A81B6A536A98" {
|
||||
t.Errorf("Expected MAC 'A81B6A536A98', got '%s'", migratedInfo.MacAddress)
|
||||
}
|
||||
|
||||
if migratedInfo.DeviceSerialNumber != "I6332527703739342000020" {
|
||||
t.Errorf("Expected device serial 'I6332527703739342000020', got '%s'", migratedInfo.DeviceSerialNumber)
|
||||
}
|
||||
|
||||
t.Logf("\nMigration completed successfully:")
|
||||
t.Logf(" New device ID: %s (MAC address)", migratedInfo.DeviceID)
|
||||
t.Logf(" Device name: %s", migratedInfo.Name)
|
||||
t.Logf(" Device serial: %s", migratedInfo.DeviceSerialNumber)
|
||||
t.Logf(" Product serial: %s", migratedInfo.ProductSerialNumber)
|
||||
t.Logf(" MAC address: %s", migratedInfo.MacAddress)
|
||||
|
||||
// Verify MAC address resolution works
|
||||
if err := ds.Initialize(); err != nil {
|
||||
t.Fatalf("Failed to initialize datastore: %v", err)
|
||||
}
|
||||
|
||||
resolvedDir := ds.AccountDeviceDir(accountID, "A81B6A536A98")
|
||||
expectedDir := ds.AccountDeviceDir(accountID, expectedDeviceID)
|
||||
|
||||
if resolvedDir != expectedDir {
|
||||
t.Errorf("MAC resolution failed. Expected '%s', got '%s'", expectedDir, resolvedDir)
|
||||
}
|
||||
|
||||
t.Logf("\nMAC address resolution verified:")
|
||||
t.Logf(" MAC 'A81B6A536A98' resolves to correct device directory")
|
||||
|
||||
// Note: In a complete implementation, we'd also verify that presets from all
|
||||
// the old devices were consolidated, but that requires more sophisticated
|
||||
// preset merging logic which is beyond the current migration scope.
|
||||
|
||||
t.Logf("\n✅ Comprehensive migration test completed successfully!")
|
||||
}
|
||||
|
||||
func TestFindAllExistingDeviceVariants_MatchingCriteria(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "variants-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
srv := NewServer(ds, nil, "http://localhost", false, false, false, false, false, false)
|
||||
accountID := "testaccount"
|
||||
|
||||
// Create devices that should match various criteria
|
||||
devices := []models.ServiceDeviceInfo{
|
||||
{
|
||||
DeviceID: "192.168.1.100",
|
||||
AccountID: accountID,
|
||||
Name: "IP Device",
|
||||
IPAddress: "192.168.1.100",
|
||||
DeviceSerialNumber: "SERIAL123",
|
||||
MacAddress: "AA:BB:CC:DD:EE:FF",
|
||||
},
|
||||
{
|
||||
DeviceID: "SERIAL123",
|
||||
AccountID: accountID,
|
||||
Name: "Sound Speaker",
|
||||
IPAddress: "192.168.1.101", // Different IP
|
||||
DeviceSerialNumber: "SERIAL123",
|
||||
MacAddress: "AA:BB:CC:DD:EE:FF",
|
||||
},
|
||||
{
|
||||
DeviceID: "UPnPSerial456",
|
||||
AccountID: accountID,
|
||||
Name: "Sound Speaker",
|
||||
IPAddress: "192.168.1.102", // Different IP again
|
||||
ProductCode: "SoundTouch 10 sm2",
|
||||
},
|
||||
{
|
||||
DeviceID: "UnrelatedDevice",
|
||||
AccountID: accountID,
|
||||
Name: "Other Device",
|
||||
IPAddress: "192.168.1.200",
|
||||
DeviceSerialNumber: "OTHERSSERIAL",
|
||||
},
|
||||
}
|
||||
|
||||
for _, device := range devices {
|
||||
if err := ds.SaveDeviceInfo(accountID, device.DeviceID, &device); err != nil {
|
||||
t.Fatalf("Failed to save device %s: %v", device.DeviceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create mock discovery and live info
|
||||
discovery := models.DiscoveredDevice{
|
||||
Host: "192.168.1.100", // Matches first device by IP
|
||||
SerialNo: "UPnPSerial456", // Matches third device by UPnP serial
|
||||
}
|
||||
|
||||
liveInfo := &setup.DeviceInfoXML{
|
||||
DeviceID: "AABBCCDDEEFF", // New MAC-based ID
|
||||
Name: "Sound Speaker", // Matches second and third devices by name
|
||||
Type: "SoundTouch 10",
|
||||
ModuleType: "sm2",
|
||||
SerialNumber: "SERIAL123", // Matches first and second devices by serial
|
||||
NetworkInfo: []struct {
|
||||
Type string `xml:"type,attr"`
|
||||
MacAddress string `xml:"macAddress"`
|
||||
IPAddress string `xml:"ipAddress"`
|
||||
}{
|
||||
{Type: "SCM", MacAddress: "AA:BB:CC:DD:EE:FF", IPAddress: "192.168.1.100"},
|
||||
},
|
||||
}
|
||||
|
||||
// Test the matching logic
|
||||
matches := srv.findAllExistingDeviceVariants(discovery, liveInfo)
|
||||
|
||||
t.Logf("Found %d matching device variants:", len(matches))
|
||||
for i, match := range matches {
|
||||
t.Logf(" %d. %s (IP: %s, Serial: %s, MAC: %s, Name: %s)",
|
||||
i+1, match.DeviceID, match.IPAddress, match.DeviceSerialNumber, match.MacAddress, match.Name)
|
||||
}
|
||||
|
||||
// Verify expected matches
|
||||
expectedMatches := map[string]string{
|
||||
"192.168.1.100": "IP match",
|
||||
"SERIAL123": "Serial match",
|
||||
"UPnPSerial456": "UPnP serial match",
|
||||
}
|
||||
|
||||
if len(matches) != len(expectedMatches) {
|
||||
t.Errorf("Expected %d matches, got %d", len(expectedMatches), len(matches))
|
||||
}
|
||||
|
||||
foundMatches := make(map[string]bool)
|
||||
for _, match := range matches {
|
||||
foundMatches[match.DeviceID] = true
|
||||
}
|
||||
|
||||
for expectedID, reason := range expectedMatches {
|
||||
if !foundMatches[expectedID] {
|
||||
t.Errorf("Expected to find device %s (%s), but it was not matched", expectedID, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify UnrelatedDevice is NOT matched
|
||||
if foundMatches["UnrelatedDevice"] {
|
||||
t.Error("UnrelatedDevice should not have been matched, but it was")
|
||||
}
|
||||
|
||||
t.Logf("✅ Device variant matching test completed successfully!")
|
||||
}
|
||||
|
||||
func TestMigration_EdgeCases(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "migration-edge-cases-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
srv := NewServer(ds, nil, "http://localhost", false, false, false, false, false, false)
|
||||
accountID := "testaccount"
|
||||
|
||||
t.Run("NoExistingDevices", func(t *testing.T) {
|
||||
discovery := models.DiscoveredDevice{Host: "192.168.1.200"}
|
||||
liveInfo := &setup.DeviceInfoXML{DeviceID: "NEWMAC123"}
|
||||
|
||||
matches := srv.findAllExistingDeviceVariants(discovery, liveInfo)
|
||||
if len(matches) != 0 {
|
||||
t.Errorf("Expected 0 matches for new device, got %d", len(matches))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SelfMatch", func(t *testing.T) {
|
||||
// Device already exists with MAC as deviceID
|
||||
macDeviceID := "AABBCCDDEEFF"
|
||||
existing := &models.ServiceDeviceInfo{
|
||||
DeviceID: macDeviceID,
|
||||
AccountID: accountID,
|
||||
Name: "Existing MAC Device",
|
||||
IPAddress: "192.168.1.150",
|
||||
}
|
||||
if err := ds.SaveDeviceInfo(accountID, macDeviceID, existing); err != nil {
|
||||
t.Fatalf("Failed to save MAC device: %v", err)
|
||||
}
|
||||
|
||||
discovery := models.DiscoveredDevice{Host: "192.168.1.150"}
|
||||
liveInfo := &setup.DeviceInfoXML{DeviceID: macDeviceID} // Same MAC
|
||||
|
||||
matches := srv.findAllExistingDeviceVariants(discovery, liveInfo)
|
||||
|
||||
// Should find itself, but migration logic should skip it since deviceID matches
|
||||
found := false
|
||||
for _, match := range matches {
|
||||
if match.DeviceID == macDeviceID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("Device should find itself in variants")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PartialMatches", func(t *testing.T) {
|
||||
// Device with some matching criteria but not others
|
||||
partialDevice := &models.ServiceDeviceInfo{
|
||||
DeviceID: "PARTIAL123",
|
||||
AccountID: accountID,
|
||||
Name: "Partial Device",
|
||||
IPAddress: "192.168.1.160", // Different IP
|
||||
// No serial number, no MAC
|
||||
}
|
||||
if err := ds.SaveDeviceInfo(accountID, "PARTIAL123", partialDevice); err != nil {
|
||||
t.Fatalf("Failed to save partial device: %v", err)
|
||||
}
|
||||
|
||||
discovery := models.DiscoveredDevice{Host: "192.168.1.170"} // Different IP
|
||||
liveInfo := &setup.DeviceInfoXML{
|
||||
DeviceID: "NEWMAC456",
|
||||
Name: "Partial Device", // Same name
|
||||
Type: "SoundTouch 20",
|
||||
}
|
||||
|
||||
matches := srv.findAllExistingDeviceVariants(discovery, liveInfo)
|
||||
|
||||
// Should match by name and product type
|
||||
found := false
|
||||
for _, match := range matches {
|
||||
if match.DeviceID == "PARTIAL123" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("Should match device by name and product type")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func TestDeviceMigration_DirectoryRename(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "migration-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
srv := NewServer(ds, nil, "http://localhost", false, false, false, false, true, false)
|
||||
|
||||
accountID := "test-account"
|
||||
macAddress := "A81B6A536A98"
|
||||
serialNumber := "I6332527703739342000020"
|
||||
|
||||
// Create serial-based device entry with full data (simulates legacy directory)
|
||||
serialDeviceInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: serialNumber,
|
||||
AccountID: accountID,
|
||||
Name: "Living Room Speaker",
|
||||
MacAddress: macAddress,
|
||||
DeviceSerialNumber: serialNumber,
|
||||
ProductCode: "SoundTouch 30",
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(accountID, serialNumber, serialDeviceInfo); err != nil {
|
||||
t.Fatalf("Failed to save serial-based device: %v", err)
|
||||
}
|
||||
|
||||
// Create some preset data in the serial-based directory
|
||||
serialPresets := []models.ServicePreset{
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ID: "1",
|
||||
Name: "My Preset",
|
||||
Source: "SPOTIFY",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := ds.SavePresets(accountID, serialNumber, serialPresets); err != nil {
|
||||
t.Fatalf("Failed to save presets: %v", err)
|
||||
}
|
||||
|
||||
// Verify initial state - serial directory exists
|
||||
serialDir := ds.AccountDeviceDir(accountID, serialNumber)
|
||||
if _, err := os.Stat(serialDir); os.IsNotExist(err) {
|
||||
t.Fatalf("Serial directory should exist before migration: %s", serialDir)
|
||||
}
|
||||
|
||||
// Perform migration using migration manager
|
||||
existingDevices := []models.ServiceDeviceInfo{*serialDeviceInfo}
|
||||
srv.migrationManager.MigrateDevicesIfNeeded(existingDevices, macAddress)
|
||||
|
||||
// Verify migration results
|
||||
t.Run("VerifyMigration", func(t *testing.T) {
|
||||
// 1. MAC directory should exist with files
|
||||
macDir := ds.AccountDeviceDir(accountID, macAddress)
|
||||
serialDir := ds.AccountDeviceDir(accountID, serialNumber)
|
||||
|
||||
if _, err := os.Stat(macDir); os.IsNotExist(err) {
|
||||
t.Errorf("MAC directory should exist after migration: %s", macDir)
|
||||
}
|
||||
|
||||
// 2. Serial directory should be gone
|
||||
if _, err := os.Stat(serialDir); !os.IsNotExist(err) {
|
||||
t.Errorf("Serial directory should not exist after migration: %s", serialDir)
|
||||
}
|
||||
|
||||
// 3. Simulate SaveDeviceInfo with fresh data (like real discovery flow)
|
||||
// This overwrites DeviceInfo.xml with correct MAC-based deviceID
|
||||
freshDeviceInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: macAddress,
|
||||
AccountID: accountID,
|
||||
Name: "Sound Speaker Fresh",
|
||||
IPAddress: "192.168.1.100",
|
||||
MacAddress: macAddress,
|
||||
DeviceSerialNumber: serialNumber,
|
||||
ProductCode: "SoundTouch 10 sm2",
|
||||
FirmwareVersion: "3.4.6.2356",
|
||||
ProductSerialNumber: "069231P63364828AE",
|
||||
DiscoveryMethod: "Migration Test",
|
||||
}
|
||||
if err := ds.SaveDeviceInfo(accountID, macAddress, freshDeviceInfo); err != nil {
|
||||
t.Errorf("Failed to save fresh device info: %v", err)
|
||||
}
|
||||
|
||||
// 4. Device info should now have correct MAC-based deviceID
|
||||
macInfo, err := ds.GetDeviceInfo(accountID, macAddress)
|
||||
if err != nil {
|
||||
t.Errorf("Should be able to get device info with MAC ID: %v", err)
|
||||
} else if macInfo.DeviceID != macAddress {
|
||||
t.Errorf("DeviceID should be updated to MAC address, got %s", macInfo.DeviceID)
|
||||
}
|
||||
|
||||
// 5. All data should be accessible through MAC address
|
||||
presets, err := ds.GetPresets(accountID, macAddress)
|
||||
if err != nil {
|
||||
t.Errorf("Should be able to get presets through MAC address: %v", err)
|
||||
} else if len(presets) != 1 || presets[0].Name != "My Preset" {
|
||||
t.Errorf("Presets should be preserved during migration")
|
||||
}
|
||||
|
||||
t.Logf("✓ Device directory migration working correctly")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeviceMigration_NoExistingTarget(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "no-target-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
srv := NewServer(ds, nil, "http://localhost", false, false, false, false, true, false)
|
||||
|
||||
accountID := "test-account"
|
||||
macAddress := "A81B6A536A98"
|
||||
serialNumber := "I6332527703739342000020"
|
||||
|
||||
// Create only serial-based device entry (no existing MAC directory)
|
||||
serialDeviceInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: serialNumber,
|
||||
AccountID: accountID,
|
||||
Name: "Test Speaker",
|
||||
MacAddress: macAddress,
|
||||
DeviceSerialNumber: serialNumber,
|
||||
ProductCode: "SoundTouch 30",
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(accountID, serialNumber, serialDeviceInfo); err != nil {
|
||||
t.Fatalf("Failed to save serial device: %v", err)
|
||||
}
|
||||
|
||||
// Add some data files
|
||||
presets := []models.ServicePreset{
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ID: "1",
|
||||
Name: "Test Preset",
|
||||
Source: "SPOTIFY",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := ds.SavePresets(accountID, serialNumber, presets); err != nil {
|
||||
t.Fatalf("Failed to save presets: %v", err)
|
||||
}
|
||||
|
||||
// Verify MAC directory doesn't exist initially
|
||||
macDir := ds.AccountDeviceDir(accountID, macAddress)
|
||||
if _, err := os.Stat(macDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("MAC directory should not exist initially: %s", macDir)
|
||||
}
|
||||
|
||||
// Migrate directory using migration manager
|
||||
existingDevices := []models.ServiceDeviceInfo{*serialDeviceInfo}
|
||||
srv.migrationManager.MigrateDevicesIfNeeded(existingDevices, macAddress)
|
||||
|
||||
// Verify migration
|
||||
if _, err := os.Stat(macDir); os.IsNotExist(err) {
|
||||
t.Errorf("MAC directory should exist after migration: %s", macDir)
|
||||
}
|
||||
|
||||
// Verify data is accessible
|
||||
migratedPresets, err := ds.GetPresets(accountID, macAddress)
|
||||
if err != nil {
|
||||
t.Errorf("Should be able to access presets after migration: %v", err)
|
||||
} else if len(migratedPresets) != 1 || migratedPresets[0].Name != "Test Preset" {
|
||||
t.Errorf("Presets should be preserved in migration")
|
||||
}
|
||||
|
||||
t.Log("✓ Simple directory migration working correctly")
|
||||
}
|
||||
|
||||
func TestDeviceMigration_ExistingTargetRemoved(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "existing-target-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
srv := NewServer(ds, nil, "http://localhost", false, false, false, false, true, false)
|
||||
|
||||
accountID := "test-account"
|
||||
macAddress := "A81B6A536A98"
|
||||
serialNumber := "I6332527703739342000020"
|
||||
|
||||
// Create both directories (serial has rich data, MAC has minimal data)
|
||||
serialInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: serialNumber,
|
||||
AccountID: accountID,
|
||||
Name: "Rich Data Device",
|
||||
MacAddress: macAddress,
|
||||
DeviceSerialNumber: serialNumber,
|
||||
}
|
||||
macInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: macAddress,
|
||||
AccountID: accountID,
|
||||
Name: "Minimal Data Device",
|
||||
MacAddress: macAddress,
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(accountID, serialNumber, serialInfo); err != nil {
|
||||
t.Fatalf("Failed to save serial device: %v", err)
|
||||
}
|
||||
if err := ds.SaveDeviceInfo(accountID, macAddress, macInfo); err != nil {
|
||||
t.Fatalf("Failed to save MAC device: %v", err)
|
||||
}
|
||||
|
||||
// Add rich data to serial directory
|
||||
richPresets := []models.ServicePreset{
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ID: "1",
|
||||
Name: "Rich Preset",
|
||||
Source: "SPOTIFY",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := ds.SavePresets(accountID, serialNumber, richPresets); err != nil {
|
||||
t.Fatalf("Failed to save rich presets: %v", err)
|
||||
}
|
||||
|
||||
// Migrate - should replace MAC directory with serial directory content
|
||||
existingDevices := []models.ServiceDeviceInfo{*serialInfo}
|
||||
srv.migrationManager.MigrateDevicesIfNeeded(existingDevices, macAddress)
|
||||
|
||||
// Verify the rich data is now accessible via MAC address
|
||||
finalInfo, err := ds.GetDeviceInfo(accountID, macAddress)
|
||||
if err != nil {
|
||||
t.Errorf("Should be able to get device info after migration: %v", err)
|
||||
} else if finalInfo.Name != "Rich Data Device" {
|
||||
t.Errorf("Should have rich device data, got name: %s", finalInfo.Name)
|
||||
}
|
||||
|
||||
finalPresets, err := ds.GetPresets(accountID, macAddress)
|
||||
if err != nil {
|
||||
t.Errorf("Should be able to get rich presets after migration: %v", err)
|
||||
} else if len(finalPresets) != 1 || finalPresets[0].Name != "Rich Preset" {
|
||||
t.Errorf("Should have rich presets after migration")
|
||||
}
|
||||
|
||||
t.Log("✓ Migration correctly replaces existing target with richer source")
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func TestDNSSettingsValidation(t *testing.T) {
|
||||
|
||||
r, server := setupRouter("http://localhost:8001", ds)
|
||||
|
||||
// Test Case 1: Enable DNS with empty upstream (should fallback to system DNS)
|
||||
// Test Case 1: Enable DNS with empty upstream
|
||||
update := map[string]interface{}{
|
||||
"dns_enabled": true,
|
||||
"dns_upstream": "",
|
||||
@@ -38,18 +38,14 @@ func TestDNSSettingsValidation(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200 when enabling DNS without upstream (fallback to system), got %d. Body: %s", w.Code, w.Body.String())
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400 when enabling DNS without upstream, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Verify DNS state in server
|
||||
if !server.dnsEnabled {
|
||||
t.Error("DNS should be enabled in server state")
|
||||
}
|
||||
|
||||
// Verify it TRIED to start (either it is running, or it failed due to port conflict but state is enabled)
|
||||
if !server.dnsEnabled {
|
||||
t.Error("DNS state should be enabled")
|
||||
// Verify DNS server is NOT running
|
||||
running, _ := server.GetDNSRunning()
|
||||
if running {
|
||||
t.Error("DNS server should not be running after invalid config attempt")
|
||||
}
|
||||
|
||||
// Test Case 2: Enable DNS with valid upstream
|
||||
|
||||
@@ -14,11 +14,11 @@ import (
|
||||
|
||||
func TestEventLog(t *testing.T) {
|
||||
ds := datastore.NewDataStore(t.TempDir())
|
||||
s := NewServer(ds, nil, "http://localhost", false, false, false, false, false, false)
|
||||
s := &Server{ds: ds}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/streaming/stats/usage", s.HandleUsageStats)
|
||||
r.Get("/setup/devices/{deviceId}/events", s.HandleGetDeviceEvents)
|
||||
r.Get("/devices/{deviceId}/events", s.HandleGetDeviceEvents)
|
||||
|
||||
t.Run("Record and Retrieve Events", func(t *testing.T) {
|
||||
// 1. Post a usage stat
|
||||
@@ -36,7 +36,7 @@ func TestEventLog(t *testing.T) {
|
||||
}
|
||||
|
||||
// 2. Retrieve events
|
||||
req, _ = http.NewRequest("GET", "/setup/devices/SPEAKER1/events", nil)
|
||||
req, _ = http.NewRequest("GET", "/devices/SPEAKER1/events", nil)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ type healthResp struct {
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
srv := NewServer(nil, nil, "http://localhost", false, false, false, false, false, false)
|
||||
srv := &Server{}
|
||||
r.Get("/health", srv.HandleHealth)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -28,7 +27,7 @@ func (s *Server) HandleMargeSourceProviders(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Header()["ETag"] = []string{etag}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
@@ -51,49 +50,13 @@ func (s *Server) HandleMargeAccountFull(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Header()["ETag"] = []string{etag}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargePowerOn handles the Marge power on request.
|
||||
func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Printf("[Marge] Failed to read power_on body: %v", err)
|
||||
w.WriteHeader(http.StatusOK) // Silent failure is usually better for device requests
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var req models.CustomerSupportRequest
|
||||
if err := xml.Unmarshal(body, &req); err != nil {
|
||||
log.Printf("[Marge] Failed to parse power_on body: %v", err)
|
||||
|
||||
// Fallback to remote address if body parsing fails
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
go s.PrimeDeviceWithSpotify(host)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
deviceID := req.Device.ID
|
||||
deviceIP := req.DiagnosticData.DeviceLandscape.IPAddress
|
||||
|
||||
log.Printf("[Marge] Device %s powered on (IP: %s)", deviceID, deviceIP)
|
||||
|
||||
if deviceIP != "" {
|
||||
go s.PrimeDeviceWithSpotify(deviceIP)
|
||||
} else {
|
||||
// Fallback to remote address if IP is missing from XML
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
go s.PrimeDeviceWithSpotify(host)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) HandleMargePowerOn(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -146,7 +109,7 @@ func (s *Server) HandleMargeGetEmailAddress(w http.ResponseWriter, _ *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(xml.Header))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
@@ -165,7 +128,7 @@ func (s *Server) HandleMargeGetDeviceSettings(w http.ResponseWriter, _ *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(xml.Header))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
@@ -184,18 +147,9 @@ func (s *Server) HandleMargeSoftwareUpdate(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Header()["ETag"] = []string{etag}
|
||||
|
||||
// For the account-specific firmware route, always return the software_update tag.
|
||||
// This route is specifically used by firmware like Bose_Lisa/27.0.6.
|
||||
if chi.URLParam(r, "account") != "" {
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
_, _ = w.Write([]byte(marge.SoftwareUpdateToXML()))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(swUpdateXML) > 0 {
|
||||
_, _ = w.Write(swUpdateXML)
|
||||
} else {
|
||||
@@ -220,7 +174,7 @@ func (s *Server) HandleMargePresets(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Header()["ETag"] = []string{etag}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
@@ -253,29 +207,7 @@ func (s *Server) HandleMargeUpdatePreset(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeRecents returns the Marge recents for a device.
|
||||
func (s *Server) HandleMargeRecents(w http.ResponseWriter, r *http.Request) {
|
||||
account := chi.URLParam(r, "account")
|
||||
device := chi.URLParam(r, "device")
|
||||
|
||||
etag := strconv.FormatInt(s.ds.GetETagForRecents(account, device), 10)
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := marge.RecentsToXML(s.ds, account, device)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.Header()["ETag"] = []string{etag}
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
@@ -299,7 +231,7 @@ func (s *Server) HandleMargeAddRecent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
@@ -319,7 +251,7 @@ func (s *Server) HandleMargeAddDevice(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
@@ -341,7 +273,7 @@ func (s *Server) HandleMargeRemoveDevice(w http.ResponseWriter, r *http.Request)
|
||||
func (s *Server) HandleMargeProviderSettings(w http.ResponseWriter, r *http.Request) {
|
||||
account := chi.URLParam(r, "account")
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(marge.ProviderSettingsToXML(account)))
|
||||
}
|
||||
|
||||
@@ -367,26 +299,6 @@ func (s *Server) HandleMargeStreamingToken(w http.ResponseWriter, _ *http.Reques
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeDeviceGroup returns grouping information for a device (empty group by default).
|
||||
func (s *Server) HandleMargeDeviceGroup(w http.ResponseWriter, _ *http.Request) {
|
||||
// Native firmware expects vnd.bose.streaming content type
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><group/>`))
|
||||
}
|
||||
|
||||
// HandleMargeDeviceGroupServer returns grouping server information (404 by default if not a server).
|
||||
func (s *Server) HandleMargeDeviceGroupServer(w http.ResponseWriter, r *http.Request) {
|
||||
// Not in a group as server
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
// HandleMargeDeviceGroupMember returns grouping member information (404 by default if not a member).
|
||||
func (s *Server) HandleMargeDeviceGroupMember(w http.ResponseWriter, r *http.Request) {
|
||||
// Not in a group as member
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
// HandleMargeCustomerSupport handles Marge customer support uploads.
|
||||
func (s *Server) HandleMargeCustomerSupport(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
|
||||
@@ -264,7 +264,7 @@ func TestMargeUpdatePreset(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeAddRecentRoute(t *testing.T) {
|
||||
func TestMargeDeviceInfo(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
@@ -331,298 +331,6 @@ func TestMargeAddRecentRoute(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeNativeStreamingRoutes(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-test-native-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
account := "12345"
|
||||
deviceID := "DEV1"
|
||||
|
||||
accountDir := filepath.Join(tempDir, "accounts", account)
|
||||
deviceDir := filepath.Join(accountDir, "devices", deviceID)
|
||||
err = os.MkdirAll(deviceDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create device dir: %v", err)
|
||||
}
|
||||
|
||||
// Mock Sources.xml for recent tests
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(`
|
||||
<sources>
|
||||
<source id="SRC1" displayName="TUNEIN" secret="" secretType="Audio">
|
||||
<sourceKey type="TUNEIN" account=""/>
|
||||
</source>
|
||||
</sources>
|
||||
`), 0644); err != nil {
|
||||
t.Fatalf("Failed to write Sources.xml: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(`<recents></recents>`), 0644); err != nil {
|
||||
t.Fatalf("Failed to write Recents.xml: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Presets.xml"), []byte(`<presets></presets>`), 0644); err != nil {
|
||||
t.Fatalf("Failed to write Presets.xml: %v", err)
|
||||
}
|
||||
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
t.Run("POST /streaming/account/{account}/device/{device}/recent", func(t *testing.T) {
|
||||
payload := `
|
||||
<recent>
|
||||
<name>New Route Recent</name>
|
||||
<sourceid>SRC1</sourceid>
|
||||
<location>/station/s999</location>
|
||||
<contentItemType>station</contentItemType>
|
||||
</recent>`
|
||||
|
||||
res, err := http.Post(ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", "application/xml", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
if ct := res.Header.Get("Content-Type"); ct != "application/vnd.bose.streaming-v1.2+xml" {
|
||||
t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.2+xml, got %v", ct)
|
||||
}
|
||||
|
||||
// Verify file was saved
|
||||
recentData, _ := os.ReadFile(filepath.Join(deviceDir, "Recents.xml"))
|
||||
if !strings.Contains(string(recentData), "New Route Recent") {
|
||||
t.Error("Recent from native route was not saved to datastore")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /streaming/account/{account}/full", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/streaming/account/" + account + "/full")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
if ct := res.Header.Get("Content-Type"); ct != "application/vnd.bose.streaming-v1.2+xml" {
|
||||
t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.2+xml, got %v", ct)
|
||||
}
|
||||
|
||||
fullData, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(fullData), account) {
|
||||
t.Error("Account full response does not contain account ID")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /streaming/software/update/account/{account}", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/streaming/software/update/account/" + account)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
if ct := res.Header.Get("Content-Type"); ct != "application/vnd.bose.streaming-v1.2+xml" {
|
||||
t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.2+xml, got %v", ct)
|
||||
}
|
||||
|
||||
swData, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(swData), "software_update") {
|
||||
t.Errorf("Response missing software_update tag: %s", string(swData))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /streaming/account/{account}/device/{device}/recent", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/streaming/account/" + account + "/device/" + deviceID + "/recent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
if ct := res.Header.Get("Content-Type"); ct != "application/vnd.bose.streaming-v1.2+xml" {
|
||||
t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.2+xml, got %v", ct)
|
||||
}
|
||||
|
||||
etag := res.Header.Get("ETag")
|
||||
if etag == "" {
|
||||
t.Error("Expected ETag header")
|
||||
}
|
||||
|
||||
recentData, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(recentData), "recents") {
|
||||
t.Errorf("Response missing recents tag: %s", string(recentData))
|
||||
}
|
||||
|
||||
// Test 304
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", nil)
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
res2, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res2.Body.Close()
|
||||
if res2.StatusCode != http.StatusNotModified {
|
||||
t.Errorf("Expected 304 Not Modified, got %v", res2.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /streaming/account/{account}/device/{device}/presets", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/streaming/account/" + account + "/device/" + deviceID + "/presets")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
if ct := res.Header.Get("Content-Type"); ct != "application/vnd.bose.streaming-v1.2+xml" {
|
||||
t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.2+xml, got %v", ct)
|
||||
}
|
||||
|
||||
etag := res.Header.Get("ETag")
|
||||
if etag == "" {
|
||||
t.Error("Expected ETag header")
|
||||
}
|
||||
|
||||
presetData, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(presetData), "presets") {
|
||||
t.Errorf("Response missing presets tag: %s", string(presetData))
|
||||
}
|
||||
|
||||
// Test 304
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/presets", nil)
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
res2, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res2.Body.Close()
|
||||
if res2.StatusCode != http.StatusNotModified {
|
||||
t.Errorf("Expected 304 Not Modified, got %v", res2.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST /streaming/account/{account}/device/{device}/presets/{presetNumber}", func(t *testing.T) {
|
||||
payload := `
|
||||
<preset>
|
||||
<name>New Native Preset</name>
|
||||
<sourceid>SRC1</sourceid>
|
||||
<location>/station/s777</location>
|
||||
<contentItemType>station</contentItemType>
|
||||
</preset>`
|
||||
|
||||
res, err := http.Post(ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/presets/1", "application/xml", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
if ct := res.Header.Get("Content-Type"); ct != "application/vnd.bose.streaming-v1.2+xml" {
|
||||
t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.2+xml, got %v", ct)
|
||||
}
|
||||
|
||||
// Verify file was saved
|
||||
presetData, _ := os.ReadFile(filepath.Join(deviceDir, "Presets.xml"))
|
||||
if !strings.Contains(string(presetData), "New Native Preset") {
|
||||
t.Error("Preset from native route was not saved to datastore")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /streaming/account/{account}/device/{device}/group/", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/streaming/account/" + account + "/device/" + deviceID + "/group/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
groupData, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(groupData), "<group") {
|
||||
t.Errorf("Response missing group tag: %s", string(groupData))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /streaming/account/{account}/device/{device}/group/server", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/streaming/account/" + account + "/device/" + deviceID + "/group/server")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("Expected 404 Not Found, got %v", res.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /streaming/account/{account}/device/{device}/group/member", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/streaming/account/" + account + "/device/" + deviceID + "/group/member")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("Expected 404 Not Found, got %v", res.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /marge/accounts/{account}/devices/{device}/group", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/marge/accounts/" + account + "/devices/" + deviceID + "/group")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
t.Errorf("Expected status OK, got %v: %s", res.Status, string(body))
|
||||
}
|
||||
|
||||
if ct := res.Header.Get("Content-Type"); ct != "application/vnd.bose.streaming-v1.2+xml" {
|
||||
t.Errorf("Expected Content-Type application/vnd.bose.streaming-v1.2+xml, got %v", ct)
|
||||
}
|
||||
|
||||
groupData, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(groupData), "<group") {
|
||||
t.Errorf("Response missing group tag: %s", string(groupData))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMargeAddRemoveDevice(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
@@ -706,28 +414,16 @@ func TestMargePowerOn(t *testing.T) {
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
t.Run("EmptyBody", func(t *testing.T) {
|
||||
res, err := http.Post(ts.URL+"/marge/streaming/support/power_on", "application/xml", bytes.NewReader([]byte("<powerOn/>")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
})
|
||||
res, err := http.Post(ts.URL+"/marge/streaming/support/power_on", "application/xml", bytes.NewReader([]byte("<powerOn/>")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("FullBody", func(t *testing.T) {
|
||||
payload := `<?xml version="1.0" encoding="UTF-8" ?><device-data><device id="A81B6A536A98"><serialnumber>I6332527703739342000020</serialnumber><firmware-version>27.0.6.46330</firmware-version><product product_code="SoundTouch 10 sm2" type="5"><serialnumber>069231P63364828AE</serialnumber></product></device><diagnostic-data><device-landscape><rssi>Excellent</rssi><gateway-ip-address>192.168.1.1</gateway-ip-address><macaddresses><macaddress>A81B6A536A98</macaddress></macaddresses><ip-address>192.168.1.100</ip-address><network-connection-type>Wireless</network-connection-type></device-landscape></diagnostic-data></device-data>`
|
||||
res, err := http.Post(ts.URL+"/marge/streaming/support/power_on", "application/vnd.bose.streaming-v1.2+xml", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
})
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMargeAdvancedFeatures(t *testing.T) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
//go:embed web/index.html
|
||||
var indexHTML []byte
|
||||
|
||||
//go:embed web/css/* web/js/*
|
||||
//go:embed web/migration/* web/stockholm-mini/* web/shared/*
|
||||
var webFS embed.FS
|
||||
|
||||
//go:embed static/media/*
|
||||
|
||||
@@ -103,32 +103,103 @@ func TestStaticWeb(t *testing.T) {
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// 1. Test CSS
|
||||
res, err := http.Get(ts.URL + "/web/css/style.css")
|
||||
// 1. Test Migration UI CSS
|
||||
res, err := http.Get(ts.URL + "/web/migration/style.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("CSS: Expected status OK, got %v", res.Status)
|
||||
t.Errorf("Migration CSS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
|
||||
t.Errorf("CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
t.Errorf("Migration CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 2. Test JS
|
||||
res, err = http.Get(ts.URL + "/web/js/script.js")
|
||||
// 2. Test Migration UI JS
|
||||
res, err = http.Get(ts.URL + "/web/migration/script.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("JS: Expected status OK, got %v", res.Status)
|
||||
t.Errorf("Migration JS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
|
||||
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
|
||||
t.Errorf("JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
|
||||
t.Errorf("Migration JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 3. Test Migration UI Index
|
||||
res, err = http.Get(ts.URL + "/web/migration/index.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Migration Index: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/html") {
|
||||
t.Errorf("Migration Index: Expected text/html content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 4. Test Stockholm Mini
|
||||
res, err = http.Get(ts.URL + "/web/stockholm-mini/index.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Stockholm Mini: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/html") {
|
||||
t.Errorf("Stockholm Mini: Expected text/html content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 5. Test Stockholm Mini CSS
|
||||
res, err = http.Get(ts.URL + "/web/stockholm-mini/style.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Stockholm Mini CSS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
|
||||
t.Errorf("Stockholm Mini CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 6. Test Shared CSS
|
||||
res, err = http.Get(ts.URL + "/web/shared/common.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Shared CSS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
|
||||
t.Errorf("Shared CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 7. Test Shared JS
|
||||
res, err = http.Get(ts.URL + "/web/shared/common.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Shared JS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
|
||||
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
|
||||
t.Errorf("Shared JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -216,7 +215,7 @@ func (s *Server) HandleMgmtSpotifyAccounts(w http.ResponseWriter, _ *http.Reques
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMgmtSpotifyToken returns a fresh Spotify access token for the linked account.
|
||||
// HandleMgmtSpotifyToken returns a fresh Spotify access token and username.
|
||||
func (s *Server) HandleMgmtSpotifyToken(w http.ResponseWriter, _ *http.Request) {
|
||||
s.mu.RLock()
|
||||
svc := s.spotifyService
|
||||
@@ -287,27 +286,3 @@ func (s *Server) HandleMgmtSpotifyEntity(w http.ResponseWriter, r *http.Request)
|
||||
log.Printf("[Mgmt] Failed to encode entity: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMgmtPrimeDevice triggers a Spotify priming for a specific device.
|
||||
func (s *Server) HandleMgmtPrimeDevice(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := r.URL.Query().Get("deviceId")
|
||||
|
||||
if deviceID == "" {
|
||||
http.Error(w, `{"error":"missing deviceId"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
if err != nil {
|
||||
log.Printf("[Mgmt] Prime failed: %v", err)
|
||||
http.Error(w, fmt.Sprintf(`{"error":"%v"}`, err), http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger priming
|
||||
go s.PrimeDeviceWithSpotify(deviceIP)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"Priming triggered"}`))
|
||||
}
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestHandleMgmtSpotifyInit(t *testing.T) {
|
||||
s := NewServer(nil, nil, "http://localhost", false, false, false, false, false, false)
|
||||
// No spotify service configured
|
||||
req := httptest.NewRequest("POST", "/mgmt/spotify/init", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.HandleMgmtSpotifyInit(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503, got %d", w.Code)
|
||||
}
|
||||
|
||||
// With spotify service
|
||||
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
|
||||
s.SetSpotifyService(svc)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
s.HandleMgmtSpotifyInit(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !strings.Contains(resp["redirectUrl"], "client_id=cid") {
|
||||
t.Errorf("expected redirectUrl to contain client_id=cid, got %s", resp["redirectUrl"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMgmtSpotifyAccounts(t *testing.T) {
|
||||
s := NewServer(nil, nil, "http://localhost", false, false, false, false, false, false)
|
||||
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
|
||||
s.SetSpotifyService(svc)
|
||||
|
||||
req := httptest.NewRequest("GET", "/mgmt/spotify/accounts", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.HandleMgmtSpotifyAccounts(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string][]spotify.Account
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(resp["accounts"]) != 0 {
|
||||
t.Errorf("expected 0 accounts, got %d", len(resp["accounts"]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMgmtListSpeakers(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
_, s := setupRouter("http://localhost:8000", ds)
|
||||
|
||||
req := httptest.NewRequest("GET", "/mgmt/accounts/default/speakers", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.HandleMgmtListSpeakers(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, ok := resp["speakers"]; !ok {
|
||||
t.Error("expected 'speakers' in response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMgmtSpotifyCallback(t *testing.T) {
|
||||
s := NewServer(nil, nil, "http://localhost", false, false, false, false, false, false)
|
||||
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
|
||||
s.SetSpotifyService(svc)
|
||||
|
||||
// Mock Spotify token and profile endpoints
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"access_token": "at",
|
||||
"refresh_token": "rt",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
}))
|
||||
defer tokenServer.Close()
|
||||
|
||||
profileServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": "user123",
|
||||
"display_name": "Test User",
|
||||
})
|
||||
}))
|
||||
defer profileServer.Close()
|
||||
|
||||
// Use internal members to override URLs (available because we are in the same package)
|
||||
// Actually we need to reach through s.spotifyService which is private.
|
||||
// But s.spotifyService is *spotify.Service, which we have a handle to (svc).
|
||||
// We can't access private fields of spotify.Service from handlers package.
|
||||
// Wait, I can't override tokenURL from here if it's unexported in spotify package.
|
||||
// Let's check service.go again. Yes, tokenURL and apiBase are unexported.
|
||||
|
||||
// Since I can't easily mock the external Spotify API here without exported fields,
|
||||
// I will test the error paths.
|
||||
|
||||
t.Run("Missing code", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/mgmt/spotify/callback", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.HandleMgmtSpotifyCallback(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "Missing authorization code") {
|
||||
t.Errorf("expected missing code error message, got %s", w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Spotify error", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/mgmt/spotify/callback?error=access_denied", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.HandleMgmtSpotifyCallback(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "access_denied") {
|
||||
t.Errorf("expected access_denied error message, got %s", w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleMgmtSpotifyConfirm(t *testing.T) {
|
||||
s := NewServer(nil, nil, "http://localhost", false, false, false, false, false, false)
|
||||
svc := spotify.NewSpotifyService("cid", "secret", "http://localhost/cb", t.TempDir())
|
||||
s.SetSpotifyService(svc)
|
||||
|
||||
t.Run("Missing code", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/mgmt/spotify/confirm", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.HandleMgmtSpotifyConfirm(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleMgmtDeviceEvents(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
_, s := setupRouter("http://localhost:8000", ds)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/mgmt/devices/{deviceId}/events", s.HandleMgmtDeviceEvents)
|
||||
|
||||
req := httptest.NewRequest("GET", "/mgmt/devices/device123/events", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, ok := resp["events"]; !ok {
|
||||
t.Error("expected 'events' in response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthMgmt(t *testing.T) {
|
||||
s := NewServer(nil, nil, "http://localhost", false, false, false, false, false, false)
|
||||
s.SetMgmtConfig("admin", "secret123")
|
||||
|
||||
handler := s.BasicAuthMgmt()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("OK"))
|
||||
}))
|
||||
|
||||
t.Run("Valid credentials", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/mgmt/test", nil)
|
||||
req.SetBasicAuth("admin", "secret123")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d", http.StatusOK, rr.Code)
|
||||
}
|
||||
if rr.Body.String() != "OK" {
|
||||
t.Errorf("expected body 'OK', got %q", rr.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Wrong username", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/mgmt/test", nil)
|
||||
req.SetBasicAuth("wrong", "secret123")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
if rr.Header().Get("WWW-Authenticate") == "" {
|
||||
t.Error("expected WWW-Authenticate header to be set")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Wrong password", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/mgmt/test", nil)
|
||||
req.SetBasicAuth("admin", "wrongpass")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Missing auth header", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/mgmt/test", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Empty credentials", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/mgmt/test", nil)
|
||||
req.SetBasicAuth("", "")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func TestHandleProxyRequest_RequestBodyRecording(t *testing.T) {
|
||||
defer backend.Close()
|
||||
|
||||
ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false, false, false, false)
|
||||
server := NewServer(ds, nil, "http://localhost:8000", false, false, false, false)
|
||||
server.recordEnabled = true
|
||||
server.proxyLogBody = true
|
||||
recorder := proxy.NewRecorder(tmpDir)
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fmt"
|
||||
@@ -154,13 +153,9 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
dnsEnabled := s.dnsEnabled
|
||||
dnsUpstream := s.dnsUpstream
|
||||
dnsBindAddr := s.dnsBindAddr
|
||||
mirrorEnabled := s.mirrorEnabled
|
||||
mirrorEndpoints := s.mirrorEndpoints
|
||||
internalPaths := s.internalPaths
|
||||
enableSoundcorkProxy := s.enableSoundcorkProxy
|
||||
redact, logBody, record := s.proxyRedact, s.proxyLogBody, s.recordEnabled
|
||||
shortcuts := s.shortcuts
|
||||
spotifyConfigured := s.spotifyService != nil
|
||||
s.mu.RUnlock()
|
||||
|
||||
dnsRunning, actualBind := s.GetDNSRunning()
|
||||
@@ -174,17 +169,13 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
"dns_enabled": dnsEnabled,
|
||||
"dns_running": dnsRunning,
|
||||
"dns_actual_bind": actualBind,
|
||||
"dns_upstream": strings.Join(dnsUpstream, ","),
|
||||
"dns_upstream": dnsUpstream,
|
||||
"dns_bind_addr": dnsBindAddr,
|
||||
"mirror_enabled": mirrorEnabled,
|
||||
"mirror_endpoints": mirrorEndpoints,
|
||||
"internal_paths": internalPaths,
|
||||
"enable_soundcork_proxy": enableSoundcorkProxy,
|
||||
"redact_logs": redact,
|
||||
"log_bodies": logBody,
|
||||
"record_interactions": record,
|
||||
"shortcuts": shortcuts,
|
||||
"spotify_configured": spotifyConfigured,
|
||||
}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -201,9 +192,6 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
DNSEnabled bool `json:"dns_enabled"`
|
||||
DNSUpstream string `json:"dns_upstream"`
|
||||
DNSBindAddr string `json:"dns_bind_addr"`
|
||||
MirrorEnabled bool `json:"mirror_enabled"`
|
||||
MirrorEndpoints []string `json:"mirror_endpoints"`
|
||||
InternalPaths []string `json:"internal_paths"`
|
||||
EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"`
|
||||
Shortcuts map[string]int `json:"shortcuts"`
|
||||
}
|
||||
@@ -213,9 +201,8 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if settings.DNSEnabled && settings.DNSUpstream == "" {
|
||||
// No strict requirement for DNSUpstream here as SetDNSSettings will
|
||||
// try to fall back to system DNS. We only log it if both are empty later.
|
||||
log.Printf("[DNS] DNS Discovery enabled without explicit upstreams, will try system DNS.")
|
||||
http.Error(w, "DNS Upstream is required when DNS Discovery is enabled", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
interval, err := time.ParseDuration(settings.DiscoveryInterval)
|
||||
@@ -234,26 +221,9 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
s.discoveryEnabled = settings.DiscoveryEnabled
|
||||
s.dnsEnabled = settings.DNSEnabled
|
||||
|
||||
// Handle comma-separated upstream DNS servers
|
||||
var upstreamList []string
|
||||
|
||||
if settings.DNSUpstream != "" {
|
||||
for _, u := range strings.Split(settings.DNSUpstream, ",") {
|
||||
u = strings.TrimSpace(u)
|
||||
if u != "" {
|
||||
upstreamList = append(upstreamList, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.dnsUpstream = upstreamList
|
||||
s.dnsUpstream = settings.DNSUpstream
|
||||
s.dnsBindAddr = settings.DNSBindAddr
|
||||
|
||||
s.mirrorEnabled = settings.MirrorEnabled
|
||||
s.mirrorEndpoints = settings.MirrorEndpoints
|
||||
s.internalPaths = settings.InternalPaths
|
||||
|
||||
s.enableSoundcorkProxy = settings.EnableSoundcorkProxy
|
||||
if settings.Shortcuts != nil {
|
||||
s.shortcuts = settings.Shortcuts
|
||||
@@ -283,20 +253,17 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
DNSEnabled: s.dnsEnabled,
|
||||
DNSUpstream: s.dnsUpstream,
|
||||
DNSBindAddr: s.dnsBindAddr,
|
||||
MirrorEnabled: s.mirrorEnabled,
|
||||
MirrorEndpoints: s.mirrorEndpoints,
|
||||
InternalPaths: s.internalPaths,
|
||||
EnableSoundcorkProxy: s.enableSoundcorkProxy,
|
||||
Shortcuts: s.shortcuts,
|
||||
})
|
||||
|
||||
dnsEnabled := s.dnsEnabled
|
||||
dnsUpstreamStr := strings.Join(s.dnsUpstream, ",")
|
||||
dnsUpstream := s.dnsUpstream
|
||||
dnsBindAddr := s.dnsBindAddr
|
||||
|
||||
s.mu.Unlock()
|
||||
|
||||
s.SetDNSSettings(dnsEnabled, dnsUpstreamStr, dnsBindAddr)
|
||||
s.SetDNSSettings(dnsEnabled, dnsUpstream, dnsBindAddr)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to save settings: "+err.Error(), http.StatusInternalServerError)
|
||||
@@ -311,34 +278,6 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGetDeviceInfo returns live information for a device.
|
||||
func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := s.sm.GetLiveDeviceInfo(deviceIP)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(info); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGetMigrationSummary returns a summary of the migration plan for a device.
|
||||
func (s *Server) HandleGetMigrationSummary(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
@@ -347,7 +286,7 @@ func (s *Server) HandleGetMigrationSummary(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
@@ -393,16 +332,9 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -454,16 +386,9 @@ func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -490,32 +415,6 @@ func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleGetDNSDiscoveries returns recorded DNS discoveries.
|
||||
func (s *Server) HandleGetDNSDiscoveries(w http.ResponseWriter, _ *http.Request) {
|
||||
result := s.getMergedDNSDiscoveries()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(result); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDownloadDNSDiscoveries returns recorded DNS discoveries as a downloadable JSON file.
|
||||
func (s *Server) HandleDownloadDNSDiscoveries(w http.ResponseWriter, _ *http.Request) {
|
||||
result := s.getMergedDNSDiscoveries()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\"dns-discoveries.json\"")
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetIndent("", " ")
|
||||
|
||||
if err := encoder.Encode(result); err != nil {
|
||||
log.Printf("Error encoding DNS discoveries for download: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) getMergedDNSDiscoveries() []datastore.DNSDiscoveryEntry {
|
||||
// 1. Get current in-memory discoveries
|
||||
inMemory := s.GetDNSDiscovery()
|
||||
|
||||
@@ -566,7 +465,12 @@ func (s *Server) getMergedDNSDiscoveries() []datastore.DNSDiscoveryEntry {
|
||||
log.Printf("Warning: Failed to persist merged DNS discoveries: %v", err)
|
||||
}
|
||||
|
||||
return result
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(result); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleClearDNSDiscoveries clears recorded DNS discoveries.
|
||||
@@ -603,16 +507,9 @@ func (s *Server) HandleTrustCACert(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -652,16 +549,9 @@ func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -701,16 +591,9 @@ func (s *Server) HandleRemoveRemoteServices(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -750,16 +633,9 @@ func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -881,7 +757,7 @@ func (s *Server) HandleTestHostsRedirection(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
@@ -927,7 +803,7 @@ func (s *Server) HandleTestDNSRedirection(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
@@ -969,11 +845,11 @@ func (s *Server) HandleTestDNSRedirection(w http.ResponseWriter, r *http.Request
|
||||
func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Missing deviceId", http.StatusBadRequest)
|
||||
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
@@ -1003,16 +879,9 @@ func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": err.Error()}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1045,7 +914,7 @@ func (s *Server) HandleTestConnection(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
|
||||
@@ -3,7 +3,6 @@ package handlers
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -126,52 +125,6 @@ func TestProxySettingsAPI(t *testing.T) {
|
||||
if sURL != "http://new-server:8000" || pURL != "http://new-proxy:8001" {
|
||||
t.Errorf("POST /setup/settings: Server state did not update: serverURL=%s, soundcorkURL=%s", sURL, pURL)
|
||||
}
|
||||
|
||||
// 4. Test Mirror Settings persistence
|
||||
mirrorUpdate := map[string]interface{}{
|
||||
"server_url": "http://mirror-test:8000",
|
||||
"soundcork_url": "http://mirror-test:8001",
|
||||
"mirror_enabled": true,
|
||||
"mirror_endpoints": []string{"/test/*"},
|
||||
"internal_paths": []string{"/setup/*"},
|
||||
}
|
||||
|
||||
mirrorBody, err := json.Marshal(mirrorUpdate)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal mirror settings: %v", err)
|
||||
}
|
||||
res, err = http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(mirrorBody))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("POST /setup/settings (mirror): Expected status OK, got %v", res.Status)
|
||||
}
|
||||
|
||||
// Verify server state
|
||||
server.mu.RLock()
|
||||
mEnabled := server.mirrorEnabled
|
||||
mEndpoints := server.mirrorEndpoints
|
||||
iPaths := server.internalPaths
|
||||
server.mu.RUnlock()
|
||||
|
||||
if !mEnabled || len(mEndpoints) != 1 || mEndpoints[0] != "/test/*" {
|
||||
t.Errorf("POST /setup/settings (mirror): Server state did not update: enabled=%v, endpoints=%v", mEnabled, mEndpoints)
|
||||
}
|
||||
if len(iPaths) != 1 || iPaths[0] != "/setup/*" {
|
||||
t.Errorf("POST /setup/settings (mirror): Internal paths did not update: %v", iPaths)
|
||||
}
|
||||
|
||||
// Verify persistence in datastore
|
||||
persisted, _ := ds.GetSettings()
|
||||
if !persisted.MirrorEnabled || len(persisted.MirrorEndpoints) != 1 || persisted.MirrorEndpoints[0] != "/test/*" {
|
||||
t.Errorf("POST /setup/settings (mirror): Datastore did not update: %+v", persisted)
|
||||
}
|
||||
if len(persisted.InternalPaths) != 1 || persisted.InternalPaths[0] != "/setup/*" {
|
||||
t.Errorf("POST /setup/settings (mirror): Datastore internal paths did not update: %+v", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationAndCA(t *testing.T) {
|
||||
@@ -192,20 +145,11 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
return &mockSSH{host: host}
|
||||
}
|
||||
|
||||
// Mock HTTPGet to avoid real network timeouts
|
||||
sm.HTTPGet = func(url string) (*http.Response, error) {
|
||||
if strings.HasSuffix(url, "/info") {
|
||||
xml := `<?xml version="1.0" encoding="UTF-8" ?><info deviceID="192.168.1.10"><name>Test Speaker</name><type>SoundTouch 10</type><margeAccountUUID>default</margeAccountUUID></info>`
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(xml)),
|
||||
}, nil
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Body: io.NopCloser(strings.NewReader("Not Found")),
|
||||
}, nil
|
||||
}
|
||||
// Register device in datastore so lookupIP works
|
||||
_ = ds.SaveDeviceInfo("default", "192.168.1.10", &models.ServiceDeviceInfo{
|
||||
DeviceID: "192.168.1.10",
|
||||
IPAddress: "192.168.1.10",
|
||||
})
|
||||
|
||||
r, server := setupRouter("http://localhost:8001", ds)
|
||||
server.sm = sm // Inject our manager with mock SSH
|
||||
@@ -213,13 +157,6 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// Add device to datastore for resolution
|
||||
_ = ds.SaveDeviceInfo("default", "192.168.1.10", &models.ServiceDeviceInfo{
|
||||
DeviceID: "192.168.1.10",
|
||||
IPAddress: "192.168.1.10",
|
||||
AccountID: "default",
|
||||
})
|
||||
|
||||
// 1. Test GET /setup/ca.crt
|
||||
res, err := http.Get(ts.URL + "/setup/ca.crt")
|
||||
if err != nil {
|
||||
@@ -234,8 +171,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("CA: Unexpected content type: %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 2. Test POST /setup/migrate/{deviceIP}?method=hosts
|
||||
res, err = http.Post(ts.URL+"/setup/migrate/192.168.1.10?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
|
||||
// 2. Test POST /setup/devices/{deviceIP}/migrate?method=hosts
|
||||
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/migrate?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -256,8 +193,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("Migrate: Expected output field in response")
|
||||
}
|
||||
|
||||
// 3. Test POST /setup/trust-ca/{deviceIP}
|
||||
res, err = http.Post(ts.URL+"/setup/trust-ca/192.168.1.10", "application/json", nil)
|
||||
// 3. Test POST /setup/devices/{deviceIP}/trust-ca
|
||||
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/trust-ca", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -277,8 +214,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("TrustCA: Expected output field in response")
|
||||
}
|
||||
|
||||
// 4. Test POST /setup/reboot/{deviceIP}
|
||||
res, err = http.Post(ts.URL+"/setup/reboot/192.168.1.10", "application/json", nil)
|
||||
// 4. Test POST /devices/{deviceIP}/reboot
|
||||
res, err = http.Post(ts.URL+"/devices/192.168.1.10/reboot", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -298,8 +235,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("Reboot: Expected output field in response")
|
||||
}
|
||||
|
||||
// 5. Test POST /setup/remove-remote-services/{deviceIP}
|
||||
res, err = http.Post(ts.URL+"/setup/remove-remote-services/192.168.1.10", "application/json", nil)
|
||||
// 5. Test POST /setup/devices/{deviceIP}/remove-remote-services
|
||||
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/remove-remote-services", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -331,17 +268,20 @@ func TestRemoveDevice(t *testing.T) {
|
||||
_ = ds.Initialize()
|
||||
|
||||
// Setup a dummy device in the datastore
|
||||
account := "test-account"
|
||||
account := "acc1"
|
||||
deviceID := "TEST-DEVICE-ID"
|
||||
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)
|
||||
}
|
||||
|
||||
infoFile := filepath.Join(deviceDir, "DeviceInfo.xml")
|
||||
infoXML := `<?xml version="1.0" encoding="UTF-8" ?><info deviceID="TEST-DEVICE-ID"><name>Test Device</name><type>SoundTouch 10</type></info>`
|
||||
if err := os.WriteFile(infoFile, []byte(infoXML), 0644); err != nil {
|
||||
t.Fatalf("Failed to create device info file: %v", err)
|
||||
// Register device in datastore so HandleRemoveDevice works
|
||||
_ = ds.SaveDeviceInfo(account, deviceID, &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: account,
|
||||
IPAddress: "192.168.1.100",
|
||||
})
|
||||
|
||||
// Verify directory exists where datastore expects it
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
|
||||
if _, err := os.Stat(deviceDir); err != nil {
|
||||
t.Fatalf("Device directory was not created by SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
@@ -349,7 +289,7 @@ func TestRemoveDevice(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
// 1. Verify device exists
|
||||
res, err := http.Get(ts.URL + "/setup/devices")
|
||||
res, err := http.Get(ts.URL + "/devices")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -372,7 +312,7 @@ func TestRemoveDevice(t *testing.T) {
|
||||
}
|
||||
|
||||
// 2. Remove device
|
||||
req, err := http.NewRequest(http.MethodDelete, ts.URL+"/setup/devices/"+deviceID, nil)
|
||||
req, err := http.NewRequest(http.MethodDelete, ts.URL+"/devices/"+deviceID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -387,7 +327,7 @@ func TestRemoveDevice(t *testing.T) {
|
||||
}
|
||||
|
||||
// 3. Verify device is gone
|
||||
res, err = http.Get(ts.URL + "/setup/devices")
|
||||
res, err = http.Get(ts.URL + "/devices")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestStatsHandlers(t *testing.T) {
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
s := NewServer(ds, nil, "http://localhost", false, false, false, false, false, false)
|
||||
s := &Server{ds: ds}
|
||||
|
||||
t.Run("HandleUsageStats XML", func(t *testing.T) {
|
||||
xmlData := `
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// HandleGetDeviceInfo returns live information for a device.
|
||||
func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := s.sm.GetLiveDeviceInfo(deviceIP)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Include IP address in both snake_case and camelCase for frontend compatibility
|
||||
type deviceInfoResponse struct {
|
||||
*setup.DeviceInfoXML `json:",inline"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
IPAddressCamel string `json:"ipAddress,omitempty"`
|
||||
}
|
||||
|
||||
resp := deviceInfoResponse{
|
||||
DeviceInfoXML: info,
|
||||
IPAddress: deviceIP,
|
||||
IPAddressCamel: deviceIP,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeviceKey sends a key command to a device.
|
||||
func (s *Server) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
|
||||
key := chi.URLParam(r, "key")
|
||||
if deviceID == "" || key == "" {
|
||||
http.Error(w, "Device ID and Key are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
|
||||
err = c.SendKey(key)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to send key %s to %s: %v", key, deviceIP, err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Key sent"}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeviceVolume sets the volume level for a device.
|
||||
func (s *Server) HandleDeviceVolume(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
|
||||
levelStr := chi.URLParam(r, "level")
|
||||
if deviceID == "" || levelStr == "" {
|
||||
http.Error(w, "Device ID and Level are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
level, err := strconv.Atoi(levelStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid volume level", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
|
||||
err = c.SetVolume(level)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to set volume to %d on %s: %v", level, deviceIP, err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Volume set"}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(_ *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
const (
|
||||
pongWait = 40 * time.Second
|
||||
pingPeriod = 20 * time.Second // must be less than pongWait
|
||||
)
|
||||
|
||||
// HandleDeviceWebSocket upgrades the connection and proxies device WebSocket events to the browser.
|
||||
func (s *Server) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Upgrade the HTTP connection to a WebSocket for the browser
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Create a SoundTouch WebSocket client for the target device
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
wsClient := c.NewWebSocketClient(client.DefaultWebSocketConfig())
|
||||
|
||||
// Channel-based write pump per Gorilla best practices
|
||||
sendCh := make(chan []byte, 64) // buffer to smooth bursts
|
||||
closeCh := make(chan struct{})
|
||||
|
||||
// Helper to enqueue JSON messages; drop if buffer is full to avoid blocking
|
||||
enqueue := func(v interface{}) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case sendCh <- b:
|
||||
default:
|
||||
// drop to protect connection under burst
|
||||
}
|
||||
}
|
||||
|
||||
// Reader: we don't expect messages from the browser; just keep the
|
||||
// connection alive by processing control frames and detect close.
|
||||
_ = conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
return conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
})
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
close(closeCh)
|
||||
|
||||
_ = wsClient.Disconnect()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
mt, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
log.Printf("[WebSocket] Browser connection closed for %s: %v", deviceIP, err)
|
||||
return
|
||||
}
|
||||
|
||||
if mt == websocket.CloseMessage {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Writer: single writer goroutine handles JSON writes and ping keepalive
|
||||
go func() {
|
||||
pingTicker := time.NewTicker(pingPeriod)
|
||||
|
||||
defer func() {
|
||||
pingTicker.Stop()
|
||||
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-sendCh:
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if !ok {
|
||||
_ = conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
|
||||
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
return
|
||||
}
|
||||
case <-pingTicker.C:
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
case <-closeCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Forward typed events with a simple envelope into the send queue
|
||||
wsClient.SetHandlers(&models.WebSocketEventHandlers{
|
||||
OnNowPlaying: func(e *models.NowPlayingUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "nowPlayingUpdated", "payload": e})
|
||||
},
|
||||
OnVolumeUpdated: func(e *models.VolumeUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "volumeUpdated", "payload": e})
|
||||
},
|
||||
OnConnectionState: func(e *models.ConnectionStateUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "connectionStateUpdated", "payload": e})
|
||||
},
|
||||
OnPresetUpdated: func(e *models.PresetUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "presetUpdated", "payload": e})
|
||||
},
|
||||
OnZoneUpdated: func(e *models.ZoneUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "zoneUpdated", "payload": e})
|
||||
},
|
||||
OnBassUpdated: func(e *models.BassUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "bassUpdated", "payload": e})
|
||||
},
|
||||
OnUnknownEvent: func(event *models.WebSocketEvent) {
|
||||
bytes, _ := json.Marshal(event)
|
||||
enqueue(map[string]interface{}{"type": "unknown", "payload": json.RawMessage(bytes)})
|
||||
},
|
||||
OnSpecialMessage: func(msg *models.SpecialMessage) {
|
||||
enqueue(map[string]interface{}{"type": "special", "payload": msg})
|
||||
},
|
||||
})
|
||||
|
||||
// Add a separate goroutine to monitor the device connection status
|
||||
go func() {
|
||||
wsClient.Wait()
|
||||
log.Printf("[WebSocket] Device %s client terminated", deviceIP)
|
||||
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
// Connect to the device WebSocket
|
||||
if err := wsClient.Connect(); err != nil {
|
||||
enqueue(map[string]interface{}{"type": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Optional: send an initial snapshot for convenience
|
||||
go func() {
|
||||
info, err := s.sm.GetLiveDeviceInfo(deviceIP)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Supplement with volume and now playing
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
payload := map[string]interface{}{
|
||||
"deviceID": info.DeviceID,
|
||||
"name": info.Name,
|
||||
"type": info.Type,
|
||||
"maccAddress": info.MaccAddress,
|
||||
"serialNumber": info.SerialNumber,
|
||||
"softwareVersion": info.SoftwareVer,
|
||||
// Provide IP in both styles for frontend robustness
|
||||
"ip_address": deviceIP,
|
||||
"ipAddress": deviceIP,
|
||||
}
|
||||
|
||||
if vol, err := c.GetVolume(); err == nil {
|
||||
payload["volume"] = vol
|
||||
// Also add at top level for flatter frontend parsing
|
||||
payload["actualVolume"] = vol.ActualVolume
|
||||
}
|
||||
|
||||
if np, err := c.GetNowPlaying(); err == nil {
|
||||
payload["nowPlaying"] = np
|
||||
}
|
||||
|
||||
enqueue(map[string]interface{}{"type": "snapshotInfo", "payload": payload})
|
||||
}()
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func TestInteractionHandlers(t *testing.T) {
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false, false, false, false)
|
||||
server := &Server{ds: ds}
|
||||
|
||||
t.Run("HandleGetInteractionStats_NoRecorder", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/setup/interaction-stats", nil)
|
||||
@@ -89,35 +89,6 @@ func TestInteractionHandlers(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HandleListInteractions_Mirror", func(t *testing.T) {
|
||||
// Create a mirror interaction
|
||||
sessionID := recorder.SessionID
|
||||
mirrorRelPath := filepath.Join(sessionID, "mirror", "test", "0002-12-00-01.000-GET.http")
|
||||
fullPath := filepath.Join(tmpDir, "interactions", mirrorRelPath)
|
||||
os.MkdirAll(filepath.Dir(fullPath), 0755)
|
||||
os.WriteFile(fullPath, []byte("### GET /test mirror\n\n> {% \n // Response: 200 OK\n%}\n"), 0644)
|
||||
|
||||
req := httptest.NewRequest("GET", "/setup/interactions?category=mirror", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var interactions []proxy.Interaction
|
||||
if err := json.NewDecoder(w.Body).Decode(&interactions); err != nil {
|
||||
t.Fatalf("Failed to decode interactions: %v", err)
|
||||
}
|
||||
|
||||
if len(interactions) != 1 {
|
||||
t.Errorf("Expected 1 interaction for mirror, got %d", len(interactions))
|
||||
}
|
||||
if interactions[0].Category != "mirror" {
|
||||
t.Errorf("Expected category mirror, got %s", interactions[0].Category)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HandleGetInteractionContent", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/setup/interaction-content?file="+relPath, nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -151,7 +122,10 @@ func TestRecordMiddleware(t *testing.T) {
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, true, false, false, false)
|
||||
server := &Server{
|
||||
ds: ds,
|
||||
recordEnabled: true,
|
||||
}
|
||||
recorder := proxy.NewRecorder(tmpDir)
|
||||
server.SetRecorder(recorder)
|
||||
|
||||
@@ -166,9 +140,6 @@ func TestRecordMiddleware(t *testing.T) {
|
||||
f.Flush()
|
||||
}
|
||||
})
|
||||
r.Get("/internal/test", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/test-middleware", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -187,22 +158,4 @@ func TestRecordMiddleware(t *testing.T) {
|
||||
t.Errorf("Expected status 201, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HandleRecordMiddleware_InternalPath", func(t *testing.T) {
|
||||
server.recordEnabled = true
|
||||
server.internalPaths = []string{"/internal/*"}
|
||||
req := httptest.NewRequest("GET", "/internal/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("Expected status 201, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Check if it was recorded (it shouldn't be)
|
||||
matches, _ := filepath.Glob(filepath.Join(tmpDir, "interactions", "*", "self", "internal", "*"))
|
||||
if len(matches) > 0 {
|
||||
t.Errorf("Expected no recording for internal path, found: %v", matches)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,429 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
)
|
||||
|
||||
func TestMACBasedDeviceDiscovery_Integration(t *testing.T) {
|
||||
// Create temporary datastore
|
||||
tempDir, err := os.MkdirTemp("", "mac-discovery-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Mock device info response (real-world example)
|
||||
deviceInfoXML := `<info deviceID="A81B6A536A98">
|
||||
<name>Sound Machinechen</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<margeAccountUUID>3230304</margeAccountUUID>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</softwareVersion>
|
||||
<serialNumber>I6332527703739342000020</serialNumber>
|
||||
</component>
|
||||
<component>
|
||||
<componentCategory>PackagedProduct</componentCategory>
|
||||
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</softwareVersion>
|
||||
<serialNumber>069231P63364828AE</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<margeURL>https://streaming.bose.com</margeURL>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>A81B6A536A98</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
<networkInfo type="SMSC">
|
||||
<macAddress>A81B6A849D99</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
<moduleType>sm2</moduleType>
|
||||
<variant>rhino</variant>
|
||||
<variantMode>normal</variantMode>
|
||||
<countryCode>GB</countryCode>
|
||||
<regionCode>GB</regionCode>
|
||||
</info>`
|
||||
|
||||
// Create mock HTTP server for device /info endpoint
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/info" {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, deviceInfoXML)
|
||||
} else {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Extract host from server URL for device IP
|
||||
deviceIP := server.URL[len("http://"):]
|
||||
|
||||
// Create datastore and setup manager
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
sm := setup.NewManager(server.URL, ds, nil)
|
||||
|
||||
// Create server instance
|
||||
srv := NewServer(ds, sm, "http://localhost", false, false, false, false, false, false)
|
||||
|
||||
t.Logf("Test scenario:")
|
||||
t.Logf(" Device IP: %s", deviceIP)
|
||||
t.Logf(" Mock /info endpoint: %s/info", server.URL)
|
||||
|
||||
// 1. Simulate device discovery
|
||||
discoveredDevice := models.DiscoveredDevice{
|
||||
Host: deviceIP,
|
||||
Name: "Legacy Discovery Name", // This should be overridden by /info
|
||||
ModelID: "Legacy Model",
|
||||
SerialNo: "", // No serial from discovery
|
||||
DiscoveryMethod: "UPnP",
|
||||
}
|
||||
|
||||
t.Logf("\n1. Simulating device discovery...")
|
||||
t.Logf(" Discovery name: %s", discoveredDevice.Name)
|
||||
t.Logf(" Discovery model: %s", discoveredDevice.ModelID)
|
||||
t.Logf(" Discovery serial: %s", discoveredDevice.SerialNo)
|
||||
|
||||
// 2. Handle discovered device (this should fetch /info and use MAC as deviceID)
|
||||
srv.handleDiscoveredDevice(discoveredDevice)
|
||||
|
||||
// 3. Verify the device was saved with MAC address as deviceID
|
||||
expectedDeviceID := "A81B6A536A98" // MAC address from /info
|
||||
expectedAccountID := "3230304" // From margeAccountUUID
|
||||
|
||||
deviceInfo, err := ds.GetDeviceInfo(expectedAccountID, expectedDeviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get device info: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("\n2. Device saved successfully:")
|
||||
t.Logf(" Device ID: %s (MAC address from /info)", deviceInfo.DeviceID)
|
||||
t.Logf(" Account ID: %s", deviceInfo.AccountID)
|
||||
t.Logf(" Device Name: %s (from /info, not discovery)", deviceInfo.Name)
|
||||
t.Logf(" Product Code: %s", deviceInfo.ProductCode)
|
||||
t.Logf(" MAC Address: %s", deviceInfo.MacAddress)
|
||||
t.Logf(" IP Address: %s", deviceInfo.IPAddress)
|
||||
t.Logf(" Device Serial: %s", deviceInfo.DeviceSerialNumber)
|
||||
t.Logf(" Product Serial: %s", deviceInfo.ProductSerialNumber)
|
||||
t.Logf(" Firmware: %s", deviceInfo.FirmwareVersion)
|
||||
t.Logf(" Discovery Method: %s", deviceInfo.DiscoveryMethod)
|
||||
|
||||
// Verify key fields
|
||||
if deviceInfo.DeviceID != expectedDeviceID {
|
||||
t.Errorf("Expected deviceID '%s', got '%s'", expectedDeviceID, deviceInfo.DeviceID)
|
||||
}
|
||||
|
||||
if deviceInfo.AccountID != expectedAccountID {
|
||||
t.Errorf("Expected accountID '%s', got '%s'", expectedAccountID, deviceInfo.AccountID)
|
||||
}
|
||||
|
||||
if deviceInfo.Name != "Sound Machinechen" {
|
||||
t.Errorf("Expected name 'Sound Machinechen' (from /info), got '%s'", deviceInfo.Name)
|
||||
}
|
||||
|
||||
if deviceInfo.ProductCode != "SoundTouch 10 sm2" {
|
||||
t.Errorf("Expected productCode 'SoundTouch 10 sm2', got '%s'", deviceInfo.ProductCode)
|
||||
}
|
||||
|
||||
if deviceInfo.MacAddress != "A81B6A536A98" {
|
||||
t.Errorf("Expected macAddress 'A81B6A536A98', got '%s'", deviceInfo.MacAddress)
|
||||
}
|
||||
|
||||
if deviceInfo.DeviceSerialNumber != "I6332527703739342000020" {
|
||||
t.Errorf("Expected deviceSerial 'I6332527703739342000020', got '%s'", deviceInfo.DeviceSerialNumber)
|
||||
}
|
||||
|
||||
if deviceInfo.ProductSerialNumber != "069231P63364828AE" {
|
||||
t.Errorf("Expected productSerial '069231P63364828AE', got '%s'", deviceInfo.ProductSerialNumber)
|
||||
}
|
||||
|
||||
expectedFirmware := "27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29"
|
||||
if deviceInfo.FirmwareVersion != expectedFirmware {
|
||||
t.Errorf("Expected firmware '%s', got '%s'", expectedFirmware, deviceInfo.FirmwareVersion)
|
||||
}
|
||||
|
||||
if deviceInfo.DiscoveryMethod != "UPnP" {
|
||||
t.Errorf("Expected discoveryMethod 'UPnP', got '%s'", deviceInfo.DiscoveryMethod)
|
||||
}
|
||||
|
||||
// 4. Verify directory structure uses MAC address
|
||||
expectedDir := filepath.Join(tempDir, "accounts", expectedAccountID, "devices", expectedDeviceID)
|
||||
if _, err := os.Stat(expectedDir); os.IsNotExist(err) {
|
||||
t.Errorf("Expected device directory not found: %s", expectedDir)
|
||||
} else {
|
||||
t.Logf("\n3. Directory structure verified:")
|
||||
t.Logf(" Device directory: %s", expectedDir)
|
||||
}
|
||||
|
||||
// 5. Verify DeviceInfo.xml file contains MAC address in networkInfo
|
||||
deviceInfoPath := filepath.Join(expectedDir, "DeviceInfo.xml")
|
||||
xmlData, err := os.ReadFile(deviceInfoPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read DeviceInfo.xml: %v", err)
|
||||
}
|
||||
|
||||
var savedXML struct {
|
||||
XMLName xml.Name `xml:"info"`
|
||||
DeviceID string `xml:"deviceID,attr"`
|
||||
NetworkInfo []struct {
|
||||
Type string `xml:"type,attr"`
|
||||
MacAddress string `xml:"macAddress"`
|
||||
IPAddress string `xml:"ipAddress"`
|
||||
} `xml:"networkInfo"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(xmlData, &savedXML); err != nil {
|
||||
t.Fatalf("Failed to parse saved DeviceInfo.xml: %v", err)
|
||||
}
|
||||
|
||||
if savedXML.DeviceID != expectedDeviceID {
|
||||
t.Errorf("Expected saved deviceID '%s', got '%s'", expectedDeviceID, savedXML.DeviceID)
|
||||
}
|
||||
|
||||
// Verify MAC address in networkInfo
|
||||
macFound := false
|
||||
for _, net := range savedXML.NetworkInfo {
|
||||
if net.Type == "SCM" && net.MacAddress == "A81B6A536A98" {
|
||||
macFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !macFound {
|
||||
t.Error("MAC address not found in saved DeviceInfo.xml networkInfo")
|
||||
}
|
||||
|
||||
t.Logf("\n4. DeviceInfo.xml verification:")
|
||||
t.Logf(" File exists: %s", deviceInfoPath)
|
||||
t.Logf(" Contains MAC in networkInfo: %v", macFound)
|
||||
|
||||
// 6. Initialize datastore to populate MAC mappings
|
||||
if err := ds.Initialize(); err != nil {
|
||||
t.Fatalf("Failed to initialize datastore: %v", err)
|
||||
}
|
||||
|
||||
// 7. Test MAC address resolution
|
||||
resolvedDir := ds.AccountDeviceDir(expectedAccountID, "A81B6A536A98") // Use MAC as device lookup
|
||||
expectedResolvedDir := ds.AccountDeviceDir(expectedAccountID, expectedDeviceID)
|
||||
|
||||
if resolvedDir != expectedResolvedDir {
|
||||
t.Errorf("MAC resolution failed. Expected '%s', got '%s'", expectedResolvedDir, resolvedDir)
|
||||
} else {
|
||||
t.Logf("\n5. MAC address resolution verified:")
|
||||
t.Logf(" MAC 'A81B6A536A98' resolves to correct device directory")
|
||||
}
|
||||
|
||||
t.Logf("\n✅ MAC-based device discovery integration test passed!")
|
||||
t.Logf("Summary:")
|
||||
t.Logf(" • Discovery finds device IP: %s", deviceIP)
|
||||
t.Logf(" • /info provides canonical deviceID: %s (MAC address)", expectedDeviceID)
|
||||
t.Logf(" • Device stored in account: %s", expectedAccountID)
|
||||
t.Logf(" • Directory uses MAC address: %s", expectedDeviceID)
|
||||
t.Logf(" • DeviceInfo.xml contains full device details from /info")
|
||||
t.Logf(" • MAC address resolution works for API endpoints")
|
||||
}
|
||||
|
||||
func TestMACBasedDeviceDiscovery_MigrationScenario(t *testing.T) {
|
||||
// Test scenario where we have existing device stored by IP/serial and need to migrate to MAC
|
||||
tempDir, err := os.MkdirTemp("", "mac-migration-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
accountID := "3230304"
|
||||
|
||||
// 1. Create an existing device entry using IP address (old style)
|
||||
oldDeviceID := "192.168.1.100"
|
||||
oldInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: oldDeviceID,
|
||||
AccountID: accountID,
|
||||
Name: "Old Device Name",
|
||||
IPAddress: oldDeviceID,
|
||||
ProductCode: "Unknown Model",
|
||||
FirmwareVersion: "0.0.0",
|
||||
DiscoveryMethod: "UPnP",
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(accountID, oldDeviceID, oldInfo); err != nil {
|
||||
t.Fatalf("Failed to save old device info: %v", err)
|
||||
}
|
||||
|
||||
// Save some test presets for the old device
|
||||
testPresets := []models.ServicePreset{
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ID: "1",
|
||||
Source: "SPOTIFY",
|
||||
Location: "spotify://playlist/test",
|
||||
Name: "Test Playlist",
|
||||
},
|
||||
CreatedOn: "2024-01-01T00:00:00Z",
|
||||
UpdatedOn: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
}
|
||||
if err := ds.SavePresets(accountID, oldDeviceID, testPresets); err != nil {
|
||||
t.Fatalf("Failed to save test presets: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Test scenario: Device migration")
|
||||
t.Logf(" Old device ID: %s (IP address)", oldDeviceID)
|
||||
t.Logf(" Test presets saved: %d", len(testPresets))
|
||||
|
||||
// 2. Mock the same device now providing proper /info response
|
||||
deviceInfoXML := `<info deviceID="A81B6A536A98">
|
||||
<name>Sound Machinechen</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<margeAccountUUID>3230304</margeAccountUUID>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>27.0.6.46330.5043500</softwareVersion>
|
||||
<serialNumber>I6332527703739342000020</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>A81B6A536A98</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
<moduleType>sm2</moduleType>
|
||||
</info>`
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/info" {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, deviceInfoXML)
|
||||
} else {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
deviceIP := server.URL[len("http://"):]
|
||||
sm := setup.NewManager(server.URL, ds, nil)
|
||||
srv := NewServer(ds, sm, server.URL, false, false, false, false, false, false)
|
||||
|
||||
// 3. Simulate rediscovery of the same device (now with /info working)
|
||||
discoveredDevice := models.DiscoveredDevice{
|
||||
Host: deviceIP,
|
||||
Name: "Discovery Name",
|
||||
ModelID: "Discovery Model",
|
||||
SerialNo: "",
|
||||
DiscoveryMethod: "UPnP",
|
||||
}
|
||||
|
||||
// 4. Handle discovered device - should migrate from old ID to MAC
|
||||
srv.handleDiscoveredDevice(discoveredDevice)
|
||||
|
||||
// 5. Verify new device exists with MAC as deviceID
|
||||
newDeviceID := "A81B6A536A98"
|
||||
newInfo, err := ds.GetDeviceInfo(accountID, newDeviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get migrated device info: %v", err)
|
||||
}
|
||||
|
||||
if newInfo.DeviceID != newDeviceID {
|
||||
t.Errorf("Expected new deviceID '%s', got '%s'", newDeviceID, newInfo.DeviceID)
|
||||
}
|
||||
|
||||
if newInfo.Name != "Sound Machinechen" {
|
||||
t.Errorf("Expected name from /info 'Sound Machinechen', got '%s'", newInfo.Name)
|
||||
}
|
||||
|
||||
t.Logf("\nMigration completed:")
|
||||
t.Logf(" New device ID: %s (MAC address)", newInfo.DeviceID)
|
||||
t.Logf(" Updated name: %s (from /info)", newInfo.Name)
|
||||
t.Logf(" Updated product: %s", newInfo.ProductCode)
|
||||
|
||||
// 6. Verify old device directory no longer exists (after cleanup)
|
||||
// Note: The actual cleanup happens in migrateDeviceFiles, which in our current
|
||||
// implementation is a placeholder. For this test, we'll just verify the new device exists.
|
||||
|
||||
// 7. Verify presets are accessible via new device ID
|
||||
// (In a full implementation, presets would be migrated)
|
||||
newPresets, err := ds.GetPresets(accountID, newDeviceID)
|
||||
if err != nil {
|
||||
// This is expected if migration hasn't been fully implemented
|
||||
t.Logf("Presets migration: %v (migration implementation pending)", err)
|
||||
} else {
|
||||
t.Logf("Presets migrated successfully: %d presets", len(newPresets))
|
||||
}
|
||||
|
||||
t.Logf("\n✅ MAC-based device migration test completed!")
|
||||
}
|
||||
|
||||
func TestMACBasedDeviceDiscovery_FallbackScenario(t *testing.T) {
|
||||
// Test scenario where /info endpoint is not available
|
||||
tempDir, err := os.MkdirTemp("", "mac-fallback-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create server that returns 404 for /info
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
deviceIP := server.URL[len("http://"):]
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
sm := setup.NewManager(server.URL, ds, nil)
|
||||
|
||||
srv := NewServer(ds, sm, server.URL, false, false, false, false, false, false)
|
||||
|
||||
// Simulate device discovery with UPnP providing serial
|
||||
discoveredDevice := models.DiscoveredDevice{
|
||||
Host: deviceIP,
|
||||
Name: "Legacy Device",
|
||||
ModelID: "SoundTouch 20",
|
||||
SerialNo: "UPnP123456789", // Serial from UPnP discovery
|
||||
DiscoveryMethod: "UPnP",
|
||||
}
|
||||
|
||||
t.Logf("Test scenario: /info endpoint not available")
|
||||
t.Logf(" Device IP: %s", deviceIP)
|
||||
t.Logf(" UPnP Serial: %s", discoveredDevice.SerialNo)
|
||||
|
||||
// Handle discovered device - should fall back to UPnP serial
|
||||
srv.handleDiscoveredDevice(discoveredDevice)
|
||||
|
||||
// Verify device was saved using UPnP serial as fallback
|
||||
expectedDeviceID := "UPnP123456789"
|
||||
expectedAccountID := "default" // Should use default account when /info unavailable
|
||||
|
||||
deviceInfo, err := ds.GetDeviceInfo(expectedAccountID, expectedDeviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get fallback device info: %v", err)
|
||||
}
|
||||
|
||||
if deviceInfo.DeviceID != expectedDeviceID {
|
||||
t.Errorf("Expected fallback deviceID '%s', got '%s'", expectedDeviceID, deviceInfo.DeviceID)
|
||||
}
|
||||
|
||||
if deviceInfo.Name != "Legacy Device" {
|
||||
t.Errorf("Expected name 'Legacy Device' (from discovery), got '%s'", deviceInfo.Name)
|
||||
}
|
||||
|
||||
if deviceInfo.FirmwareVersion != "0.0.0" {
|
||||
t.Errorf("Expected unknown firmware '0.0.0', got '%s'", deviceInfo.FirmwareVersion)
|
||||
}
|
||||
|
||||
t.Logf("\nFallback handling verified:")
|
||||
t.Logf(" Device ID: %s (UPnP serial)", deviceInfo.DeviceID)
|
||||
t.Logf(" Account ID: %s (default)", deviceInfo.AccountID)
|
||||
t.Logf(" Name: %s (from discovery)", deviceInfo.Name)
|
||||
t.Logf(" Firmware: %s (unknown)", deviceInfo.FirmwareVersion)
|
||||
|
||||
t.Logf("\n✅ MAC-based discovery fallback test passed!")
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestMacMappingIntegration_HTTPHandler(t *testing.T) {
|
||||
// Create temporary directory
|
||||
tmpDir, err := os.MkdirTemp("", "mac-integration-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Setup test data (same as the issue description)
|
||||
accountID := "3230304"
|
||||
serialNumber := "I6332527703739342000020"
|
||||
macAddress := "A81B6A536A98"
|
||||
|
||||
// Create directory structure using serial number
|
||||
deviceDir := filepath.Join(tmpDir, "accounts", accountID, "devices", serialNumber)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create device dir: %v", err)
|
||||
}
|
||||
|
||||
// Create DeviceInfo.xml with MAC address
|
||||
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="` + serialNumber + `">
|
||||
<name>SoundTouch Device</name>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>4.8.1</softwareVersion>
|
||||
<serialNumber>` + serialNumber + `</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>` + macAddress + `</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
</info>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, constants.DeviceInfoFile), []byte(deviceInfoXML), 0644); err != nil {
|
||||
t.Fatalf("failed to write DeviceInfo.xml: %v", err)
|
||||
}
|
||||
|
||||
// Create Presets.xml
|
||||
presetsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets>
|
||||
<preset id="1">
|
||||
<ContentItem source="SPOTIFY" type="station" location="/station/test123" sourceAccount="spotify_user">
|
||||
<itemName>Test Preset</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<preset id="2">
|
||||
<ContentItem source="TUNEIN" type="station" location="/station/s12345" sourceAccount="">
|
||||
<itemName>Radio Station</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, constants.PresetsFile), []byte(presetsXML), 0644); err != nil {
|
||||
t.Fatalf("failed to write Presets.xml: %v", err)
|
||||
}
|
||||
|
||||
// Set a specific modification time for ETag testing
|
||||
pastTime := time.Now().Add(-1 * time.Hour)
|
||||
if err := os.Chtimes(filepath.Join(deviceDir, constants.PresetsFile), pastTime, pastTime); err != nil {
|
||||
t.Fatalf("failed to set file times: %v", err)
|
||||
}
|
||||
|
||||
// Create Sources.xml (required by marge.PresetsToXML)
|
||||
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sources>
|
||||
<source source="SPOTIFY" sourceAccount="spotify_user" status="READY" multiroomallowed="true">
|
||||
<sourceName>Spotify</sourceName>
|
||||
</source>
|
||||
<source source="TUNEIN" sourceAccount="" status="READY" multiroomallowed="true">
|
||||
<sourceName>TuneIn</sourceName>
|
||||
</source>
|
||||
</sources>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, constants.SourcesFile), []byte(sourcesXML), 0644); err != nil {
|
||||
t.Fatalf("failed to write Sources.xml: %v", err)
|
||||
}
|
||||
|
||||
// Initialize datastore and server
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
if err := ds.Initialize(); err != nil {
|
||||
t.Fatalf("failed to initialize datastore: %v", err)
|
||||
}
|
||||
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false, false, false, false)
|
||||
|
||||
// Setup router with the exact same route as in production
|
||||
router := chi.NewRouter()
|
||||
router.Route("/streaming", func(r chi.Router) {
|
||||
r.Get("/account/{account}/device/{device}/presets", server.HandleMargePresets)
|
||||
})
|
||||
|
||||
// Test 1: Request with MAC address (should work due to mapping)
|
||||
t.Run("RequestWithMACAddress", func(t *testing.T) {
|
||||
requestURL := "/streaming/account/" + accountID + "/device/" + macAddress + "/presets"
|
||||
req, err := http.NewRequest("GET", requestURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d. Response body: %s", rr.Code, rr.Body.String())
|
||||
t.Logf("Request URL: %s", requestURL)
|
||||
t.Logf("MAC address: %s", macAddress)
|
||||
t.Logf("Serial number: %s", serialNumber)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify response contains the expected presets
|
||||
var presetsResponse struct {
|
||||
Presets []struct {
|
||||
ID string `xml:"id,attr"`
|
||||
Name string `xml:"ContentItem>itemName"`
|
||||
} `xml:"preset"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(rr.Body.Bytes(), &presetsResponse); err != nil {
|
||||
t.Errorf("Failed to parse XML response: %v", err)
|
||||
t.Logf("Response body: %s", rr.Body.String())
|
||||
return
|
||||
}
|
||||
|
||||
if len(presetsResponse.Presets) != 2 {
|
||||
t.Errorf("Expected 2 presets, got %d", len(presetsResponse.Presets))
|
||||
}
|
||||
|
||||
t.Logf("✓ Successfully retrieved %d presets using MAC address %s", len(presetsResponse.Presets), macAddress)
|
||||
})
|
||||
|
||||
// Test 2: Request with serial number (should also work)
|
||||
t.Run("RequestWithSerialNumber", func(t *testing.T) {
|
||||
requestURL := "/streaming/account/" + accountID + "/device/" + serialNumber + "/presets"
|
||||
req, err := http.NewRequest("GET", requestURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d. Response body: %s", rr.Code, rr.Body.String())
|
||||
return
|
||||
}
|
||||
|
||||
t.Logf("✓ Successfully retrieved presets using serial number %s", serialNumber)
|
||||
})
|
||||
|
||||
// Test 3: Request with non-existent device ID
|
||||
t.Run("RequestWithNonExistentDevice", func(t *testing.T) {
|
||||
requestURL := "/streaming/account/" + accountID + "/device/NONEXISTENT/presets"
|
||||
req, err := http.NewRequest("GET", requestURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusInternalServerError {
|
||||
t.Errorf("Expected status 500 for non-existent device, got %d", rr.Code)
|
||||
}
|
||||
|
||||
t.Logf("✓ Correctly returned error for non-existent device")
|
||||
})
|
||||
|
||||
// Test 4: Case sensitivity test
|
||||
t.Run("RequestWithLowercaseMAC", func(t *testing.T) {
|
||||
lowercaseMAC := "a81b6a536a98"
|
||||
requestURL := "/streaming/account/" + accountID + "/device/" + lowercaseMAC + "/presets"
|
||||
req, err := http.NewRequest("GET", requestURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
|
||||
// This should fail because MAC addresses are case-sensitive
|
||||
if rr.Code == http.StatusOK {
|
||||
t.Logf("⚠️ Lowercase MAC address worked (might be unexpected): %s", lowercaseMAC)
|
||||
} else {
|
||||
t.Logf("✓ Lowercase MAC address correctly failed: %s (status: %d)", lowercaseMAC, rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
// Test 5: Verify ETag functionality
|
||||
t.Run("RequestWithETag", func(t *testing.T) {
|
||||
requestURL := "/streaming/account/" + accountID + "/device/" + macAddress + "/presets"
|
||||
|
||||
// First request to get ETag
|
||||
req1, err := http.NewRequest("GET", requestURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
rr1 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr1, req1)
|
||||
|
||||
if rr1.Code != http.StatusOK {
|
||||
t.Errorf("First request failed with status %d", rr1.Code)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract ETag from response headers (direct access needed for httptest.ResponseRecorder)
|
||||
etag := ""
|
||||
//nolint:staticcheck // SA1008: ETag header name must be case-sensitive for test
|
||||
if vals, ok := rr1.Header()["ETag"]; ok && len(vals) > 0 {
|
||||
etag = vals[0]
|
||||
}
|
||||
|
||||
if etag == "" {
|
||||
t.Errorf("No ETag header in response. Available headers: %v", rr1.Header())
|
||||
return
|
||||
}
|
||||
|
||||
// Second request with ETag
|
||||
req2, err := http.NewRequest("GET", requestURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create second request: %v", err)
|
||||
}
|
||||
req2.Header.Set("If-None-Match", etag)
|
||||
|
||||
rr2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr2, req2)
|
||||
|
||||
if rr2.Code != http.StatusNotModified {
|
||||
t.Errorf("Expected 304 Not Modified, got %d", rr2.Code)
|
||||
return
|
||||
}
|
||||
|
||||
t.Logf("✓ ETag functionality works correctly with MAC address resolution")
|
||||
})
|
||||
}
|
||||
|
||||
// TestMacMappingDebug provides debugging information about the mapping state
|
||||
func TestMacMappingDebug(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "mac-debug-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create multiple devices to test mapping
|
||||
devices := []struct {
|
||||
account string
|
||||
serial string
|
||||
mac string
|
||||
}{
|
||||
{"3230304", "I6332527703739342000020", "A81B6A536A98"},
|
||||
{"3230304", "J1234567890123456789012", "B92C7B647BA9"},
|
||||
{"5678901", "K9876543210987654321098", "C03D8C758CAA"},
|
||||
}
|
||||
|
||||
for _, device := range devices {
|
||||
deviceDir := filepath.Join(tmpDir, "accounts", device.account, "devices", device.serial)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create device dir: %v", err)
|
||||
}
|
||||
|
||||
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="` + device.serial + `">
|
||||
<name>Device ` + device.serial[0:8] + `</name>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<serialNumber>` + device.serial + `</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>` + device.mac + `</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
</info>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, constants.DeviceInfoFile), []byte(deviceInfoXML), 0644); err != nil {
|
||||
t.Fatalf("failed to write DeviceInfo.xml: %v", err)
|
||||
}
|
||||
|
||||
// Create minimal Sources.xml for each device
|
||||
sourcesXML := `<sources></sources>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, constants.SourcesFile), []byte(sourcesXML), 0644); err != nil {
|
||||
t.Fatalf("failed to write Sources.xml: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize datastore
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
if err := ds.Initialize(); err != nil {
|
||||
t.Fatalf("failed to initialize datastore: %v", err)
|
||||
}
|
||||
|
||||
// Debug output
|
||||
allDevices, err := ds.ListAllDevices()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list devices: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Found %d devices total", len(allDevices))
|
||||
for _, dev := range allDevices {
|
||||
t.Logf("Device: Account=%s, Serial=%s, MAC=%s",
|
||||
dev.AccountID, dev.DeviceSerialNumber, dev.MacAddress)
|
||||
}
|
||||
|
||||
// Test each mapping
|
||||
for _, device := range devices {
|
||||
resolvedDir := ds.AccountDeviceDir(device.account, device.mac)
|
||||
expectedDir := filepath.Join(tmpDir, "accounts", device.account, "devices", device.serial)
|
||||
|
||||
if resolvedDir == expectedDir {
|
||||
t.Logf("✓ MAC %s correctly resolves to serial %s", device.mac, device.serial)
|
||||
} else {
|
||||
t.Errorf("✗ MAC %s resolution failed: got %s, expected %s",
|
||||
device.mac, resolvedDir, expectedDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,12 @@ import (
|
||||
)
|
||||
|
||||
func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) {
|
||||
server := NewServer(ds, nil, "http://localhost:8000", false, false, false, false, false, false)
|
||||
server := NewServer(ds, nil, "http://localhost:8000", false, false, false, false)
|
||||
server.SetSoundcorkURL(targetURL)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(server.OriginMiddleware)
|
||||
r.Use(server.ShortcutMiddleware)
|
||||
r.Use(server.MirrorMiddleware)
|
||||
r.Use(server.RecordMiddleware)
|
||||
|
||||
r.Get("/", server.HandleRoot)
|
||||
@@ -37,53 +36,41 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
|
||||
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
|
||||
|
||||
streamingRoutes := func(r chi.Router) {
|
||||
r.Get("/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Get("/account/{account}/device/{device}/recent", server.HandleMargeRecents)
|
||||
r.Post("/account/{account}/device/{device}/recent", server.HandleMargeAddRecent)
|
||||
r.Get("/account/{account}/device/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/account/{account}/device/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Post("/support/power_on", server.HandleMargePowerOn)
|
||||
r.Get("/account/{account}/provider_settings", server.HandleMargeProviderSettings)
|
||||
r.Get("/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
r.Post("/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
r.Get("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
// Native group endpoint (both with and without trailing slash)
|
||||
r.Get("/account/{account}/device/{device}/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/account/{account}/device/{device}/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/account/{account}/device/{device}/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/account/{account}/device/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
r.Post("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
r.Get("/account/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Get("/software/update/account/{account}", server.HandleMargeSoftwareUpdate)
|
||||
}
|
||||
|
||||
accountsRoutes := func(r chi.Router) {
|
||||
r.Get("/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Get("/{account}/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Get("/{account}/devices/{device}/recents", server.HandleMargeRecents)
|
||||
r.Post("/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
|
||||
r.Post("/{account}/devices", server.HandleMargeAddDevice)
|
||||
r.Delete("/{account}/devices/{device}", server.HandleMargeRemoveDevice)
|
||||
r.Get("/{account}/devices/{device}/group", server.HandleMargeDeviceGroup)
|
||||
r.Get("/{account}/devices/{device}/group/", server.HandleMargeDeviceGroup)
|
||||
r.Get("/{account}/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
|
||||
r.Get("/{account}/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
|
||||
}
|
||||
|
||||
// Setup Marge for tests
|
||||
r.Route("/marge", func(r chi.Router) {
|
||||
r.Route("/streaming", streamingRoutes)
|
||||
r.Route("/accounts", accountsRoutes)
|
||||
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Post("/streaming/support/power_on", server.HandleMargePowerOn)
|
||||
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
|
||||
r.Get("/accounts/{account}/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/accounts/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Post("/accounts/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
|
||||
r.Post("/accounts/{account}/devices", server.HandleMargeAddDevice)
|
||||
r.Delete("/accounts/{account}/devices/{device}", server.HandleMargeRemoveDevice)
|
||||
r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
|
||||
r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
})
|
||||
|
||||
// Legacy or direct domain calls without /marge prefix
|
||||
r.Route("/streaming", streamingRoutes)
|
||||
r.Route("/accounts", accountsRoutes)
|
||||
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Post("/streaming/support/power_on", server.HandleMargePowerOn)
|
||||
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
|
||||
r.Get("/accounts/{account}/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/accounts/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Post("/accounts/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
|
||||
r.Post("/accounts/{account}/devices", server.HandleMargeAddDevice)
|
||||
r.Delete("/accounts/{account}/devices/{device}", server.HandleMargeRemoveDevice)
|
||||
r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
|
||||
r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
|
||||
// Setup Customer for tests
|
||||
r.Route("/customer", func(r chi.Router) {
|
||||
@@ -92,23 +79,56 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Post("/account/{account}/password", server.HandleMargeChangePassword)
|
||||
})
|
||||
|
||||
// Setup Devices for tests
|
||||
r.Route("/devices", func(r chi.Router) {
|
||||
r.Get("/", server.HandleListDiscoveredDevices)
|
||||
r.Post("/", server.HandleAddManualDevice)
|
||||
|
||||
r.Route("/{deviceId}", func(r chi.Router) {
|
||||
r.Delete("/", server.HandleRemoveDevice)
|
||||
r.Get("/events", server.HandleGetDeviceEvents)
|
||||
r.Get("/info", server.HandleGetDeviceInfo)
|
||||
r.Get("/ws", server.HandleDeviceWebSocket)
|
||||
r.Post("/key/{key}", server.HandleDeviceKey)
|
||||
r.Post("/volume/{level}", server.HandleDeviceVolume)
|
||||
r.Post("/reboot", server.HandleRebootDevice)
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/version", server.HandleGetVersionInfo)
|
||||
|
||||
// Setup Setup for tests
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
r.Get("/devices", server.HandleListDiscoveredDevices)
|
||||
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
|
||||
r.Post("/discover", server.HandleTriggerDiscovery)
|
||||
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
|
||||
r.Get("/settings", server.HandleGetSettings)
|
||||
r.Post("/settings", server.HandleUpdateSettings)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
r.Get("/proxy-settings", server.HandleGetProxySettings)
|
||||
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
|
||||
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services/{deviceId}", server.HandleRemoveRemoteServices)
|
||||
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
|
||||
r.Post("/revert/{deviceId}", server.HandleRevertMigration)
|
||||
r.Post("/reboot/{deviceId}", server.HandleRebootDevice)
|
||||
r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert)
|
||||
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
|
||||
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
r.Get("/interaction-stats", server.HandleGetInteractionStats)
|
||||
r.Get("/interactions", server.HandleListInteractions)
|
||||
r.Get("/interaction-content", server.HandleGetInteractionContent)
|
||||
r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession)
|
||||
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
|
||||
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
|
||||
|
||||
r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
|
||||
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
|
||||
|
||||
r.Route("/devices/{deviceId}", func(r chi.Router) {
|
||||
r.Get("/summary", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate", server.HandleMigrateDevice)
|
||||
r.Post("/revert", server.HandleRevertMigration)
|
||||
r.Post("/trust-ca", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services", server.HandleRemoveRemoteServices)
|
||||
r.Post("/backup", server.HandleBackupConfig)
|
||||
r.Post("/sync", server.HandleInitialSync)
|
||||
r.Post("/test-connection", server.HandleTestConnection)
|
||||
r.Post("/test-hosts", server.HandleTestHostsRedirection)
|
||||
r.Post("/test-dns", server.HandleTestDNSRedirection)
|
||||
})
|
||||
})
|
||||
|
||||
r.NotFound(server.HandleNotFound)
|
||||
|
||||
@@ -1,490 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
)
|
||||
|
||||
// DeviceMigrationDiagnostic provides detailed analysis of device migration scenarios
|
||||
type DeviceMigrationDiagnostic struct {
|
||||
server *Server
|
||||
}
|
||||
|
||||
// NewDeviceMigrationDiagnostic creates a new diagnostic instance
|
||||
func NewDeviceMigrationDiagnostic(server *Server) *DeviceMigrationDiagnostic {
|
||||
return &DeviceMigrationDiagnostic{server: server}
|
||||
}
|
||||
|
||||
// DiagnoseDeviceMigration analyzes why a specific device might not be migrating correctly
|
||||
func (d *DeviceMigrationDiagnostic) DiagnoseDeviceMigration(deviceIP string) error {
|
||||
log.Printf("=== Device Migration Diagnostic for %s ===", deviceIP)
|
||||
|
||||
// 1. Fetch live device info
|
||||
liveInfo, err := d.server.sm.GetLiveDeviceInfo(deviceIP)
|
||||
if err != nil {
|
||||
log.Printf("❌ Failed to fetch /info from %s: %v", deviceIP, err)
|
||||
return fmt.Errorf("cannot fetch /info from %s: %w", deviceIP, err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Successfully fetched /info from %s", deviceIP)
|
||||
log.Printf(" Device ID (MAC): %s", liveInfo.DeviceID)
|
||||
log.Printf(" Device Name: %s", liveInfo.Name)
|
||||
log.Printf(" Product: %s %s", liveInfo.Type, liveInfo.ModuleType)
|
||||
log.Printf(" Account: %s", liveInfo.MargeAccountUUID)
|
||||
log.Printf(" Component Serial: %s", liveInfo.SerialNumber)
|
||||
log.Printf(" Primary MAC: %s", liveInfo.GetPrimaryMacAddress())
|
||||
|
||||
// 2. List all existing devices
|
||||
allDevices, err := d.server.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
log.Printf("❌ Failed to list devices: %v", err)
|
||||
return fmt.Errorf("failed to list devices: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("\n📋 Found %d existing devices in datastore:", len(allDevices))
|
||||
|
||||
devicesByAccount := make(map[string][]models.ServiceDeviceInfo)
|
||||
|
||||
for i := range allDevices {
|
||||
device := &allDevices[i]
|
||||
devicesByAccount[device.AccountID] = append(devicesByAccount[device.AccountID], *device)
|
||||
}
|
||||
|
||||
for accountID, devices := range devicesByAccount {
|
||||
log.Printf(" Account %s: %d devices", accountID, len(devices))
|
||||
|
||||
for i := range devices {
|
||||
device := &devices[i]
|
||||
log.Printf(" %d. %s", i+1, device.DeviceID)
|
||||
log.Printf(" Name: %s", device.Name)
|
||||
log.Printf(" IP: %s", device.IPAddress)
|
||||
log.Printf(" Serial: %s", device.DeviceSerialNumber)
|
||||
log.Printf(" MAC: %s", device.MacAddress)
|
||||
log.Printf(" Product: %s", device.ProductCode)
|
||||
log.Printf(" Discovery: %s", device.DiscoveryMethod)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Simulate discovery and check matching
|
||||
log.Printf("\n🔍 Testing migration candidate matching:")
|
||||
|
||||
// Test different discovery scenarios
|
||||
testDiscoveries := []models.DiscoveredDevice{
|
||||
{
|
||||
Host: deviceIP,
|
||||
Name: "Current Discovery",
|
||||
SerialNo: "",
|
||||
DiscoveryMethod: "Manual",
|
||||
},
|
||||
{
|
||||
Host: deviceIP,
|
||||
Name: "With Live Serial",
|
||||
SerialNo: liveInfo.SerialNumber,
|
||||
DiscoveryMethod: "UPnP",
|
||||
},
|
||||
}
|
||||
|
||||
// Add test with different IPs that might match existing devices
|
||||
seenIPs := make(map[string]bool)
|
||||
|
||||
for i := range allDevices {
|
||||
device := &allDevices[i]
|
||||
if device.IPAddress != "" && device.IPAddress != deviceIP && !seenIPs[device.IPAddress] {
|
||||
seenIPs[device.IPAddress] = true
|
||||
testDiscoveries = append(testDiscoveries, models.DiscoveredDevice{
|
||||
Host: device.IPAddress,
|
||||
Name: "Previous IP Test",
|
||||
SerialNo: "",
|
||||
DiscoveryMethod: "Test",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for i := range testDiscoveries {
|
||||
testDiscovery := &testDiscoveries[i]
|
||||
log.Printf("\n Test Scenario %d: %s (IP: %s, Serial: %s)",
|
||||
i+1, testDiscovery.Name, testDiscovery.Host, testDiscovery.SerialNo)
|
||||
|
||||
matches := d.server.findAllExistingDeviceVariants(*testDiscovery, liveInfo)
|
||||
if len(matches) == 0 {
|
||||
log.Printf(" ❌ No migration candidates found")
|
||||
} else {
|
||||
log.Printf(" ✅ Found %d migration candidate(s):", len(matches))
|
||||
|
||||
for i := range matches {
|
||||
match := &matches[i]
|
||||
if match.DeviceID == liveInfo.DeviceID {
|
||||
log.Printf(" - %s ⚠️ (already uses target MAC)", match.DeviceID)
|
||||
} else {
|
||||
log.Printf(" - %s", match.DeviceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Detailed matching analysis
|
||||
log.Printf("\n🔬 Detailed Matching Analysis:")
|
||||
log.Printf(" Looking for devices that should match MAC %s...", liveInfo.DeviceID)
|
||||
|
||||
potentialMatches := d.findPotentialMatches(allDevices, liveInfo)
|
||||
if len(potentialMatches) == 0 {
|
||||
log.Printf(" ❌ No potential matches found")
|
||||
log.Printf("\n💡 Recommendations:")
|
||||
log.Printf(" - This appears to be a completely new device")
|
||||
log.Printf(" - Device will be created with MAC-based ID: %s", liveInfo.DeviceID)
|
||||
log.Printf(" - Account: %s", liveInfo.MargeAccountUUID)
|
||||
} else {
|
||||
log.Printf(" ✅ Found %d potential match(es):", len(potentialMatches))
|
||||
|
||||
for i := range potentialMatches {
|
||||
d.explainMatch(potentialMatches[i], liveInfo)
|
||||
}
|
||||
|
||||
log.Printf("\n💡 Migration Recommendations:")
|
||||
|
||||
for i := range potentialMatches {
|
||||
match := potentialMatches[i]
|
||||
if match.DeviceID != liveInfo.DeviceID {
|
||||
log.Printf(" - Migrate %s → %s", match.DeviceID, liveInfo.DeviceID)
|
||||
log.Printf(" Reason: %s", d.getMatchReason(match, liveInfo))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("\n=== End Diagnostic ===")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findPotentialMatches finds devices that could potentially be the same device
|
||||
func (d *DeviceMigrationDiagnostic) findPotentialMatches(allDevices []models.ServiceDeviceInfo, liveInfo *setup.DeviceInfoXML) []models.ServiceDeviceInfo {
|
||||
var matches []models.ServiceDeviceInfo
|
||||
|
||||
for i := range allDevices {
|
||||
device := &allDevices[i]
|
||||
if d.couldBeMatch(*device, liveInfo) {
|
||||
matches = append(matches, *device)
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
// couldBeMatch determines if a device could potentially be the same physical device
|
||||
func (d *DeviceMigrationDiagnostic) couldBeMatch(device models.ServiceDeviceInfo, liveInfo *setup.DeviceInfoXML) bool {
|
||||
// 1. Serial number match
|
||||
if liveInfo.SerialNumber != "" && device.DeviceSerialNumber == liveInfo.SerialNumber {
|
||||
return true
|
||||
}
|
||||
|
||||
// 2. DeviceID is the serial
|
||||
if liveInfo.SerialNumber != "" && device.DeviceID == liveInfo.SerialNumber {
|
||||
return true
|
||||
}
|
||||
|
||||
// 3. MAC address match
|
||||
primaryMAC := liveInfo.GetPrimaryMacAddress()
|
||||
if primaryMAC != "" && device.MacAddress == primaryMAC {
|
||||
return true
|
||||
}
|
||||
|
||||
// 4. DeviceID is already the MAC
|
||||
if device.DeviceID == liveInfo.DeviceID {
|
||||
return true
|
||||
}
|
||||
|
||||
// 5. Name and product similarity
|
||||
if liveInfo.Name != "" && device.Name == liveInfo.Name {
|
||||
expectedProduct := liveInfo.Type + " " + liveInfo.ModuleType
|
||||
if device.ProductCode == expectedProduct ||
|
||||
device.ProductCode == liveInfo.Type ||
|
||||
strings.Contains(device.ProductCode, liveInfo.Type) ||
|
||||
strings.Contains(expectedProduct, device.ProductCode) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Check if device product serial matches any component
|
||||
for _, comp := range liveInfo.Components {
|
||||
if comp.SerialNumber != "" && device.ProductSerialNumber == comp.SerialNumber {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// explainMatch provides detailed explanation of why a device matches
|
||||
func (d *DeviceMigrationDiagnostic) explainMatch(device models.ServiceDeviceInfo, liveInfo *setup.DeviceInfoXML) {
|
||||
log.Printf(" 📋 Device: %s", device.DeviceID)
|
||||
log.Printf(" Account: %s", device.AccountID)
|
||||
log.Printf(" Name: %s → %s", device.Name, liveInfo.Name)
|
||||
log.Printf(" IP: %s", device.IPAddress)
|
||||
log.Printf(" Serial: %s → %s", device.DeviceSerialNumber, liveInfo.SerialNumber)
|
||||
log.Printf(" MAC: %s → %s", device.MacAddress, liveInfo.GetPrimaryMacAddress())
|
||||
log.Printf(" Product: %s → %s %s", device.ProductCode, liveInfo.Type, liveInfo.ModuleType)
|
||||
|
||||
reasons := d.getMatchReasons(device, liveInfo)
|
||||
for _, reason := range reasons {
|
||||
log.Printf(" ✅ %s", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// getMatchReason gets the primary reason for a match
|
||||
func (d *DeviceMigrationDiagnostic) getMatchReason(device models.ServiceDeviceInfo, liveInfo *setup.DeviceInfoXML) string {
|
||||
reasons := d.getMatchReasons(device, liveInfo)
|
||||
if len(reasons) > 0 {
|
||||
return reasons[0]
|
||||
}
|
||||
|
||||
return "Unknown match reason"
|
||||
}
|
||||
|
||||
// getMatchReasons gets all reasons why a device matches
|
||||
func (d *DeviceMigrationDiagnostic) getMatchReasons(device models.ServiceDeviceInfo, liveInfo *setup.DeviceInfoXML) []string {
|
||||
var reasons []string
|
||||
|
||||
// Serial number matches
|
||||
if liveInfo.SerialNumber != "" && device.DeviceSerialNumber == liveInfo.SerialNumber {
|
||||
reasons = append(reasons, "Device serial number matches")
|
||||
}
|
||||
|
||||
if liveInfo.SerialNumber != "" && device.DeviceID == liveInfo.SerialNumber {
|
||||
reasons = append(reasons, "DeviceID matches component serial")
|
||||
}
|
||||
|
||||
// MAC address matches
|
||||
primaryMAC := liveInfo.GetPrimaryMacAddress()
|
||||
if primaryMAC != "" && device.MacAddress == primaryMAC {
|
||||
reasons = append(reasons, "MAC address matches")
|
||||
}
|
||||
|
||||
if device.DeviceID == liveInfo.DeviceID {
|
||||
reasons = append(reasons, "DeviceID matches (already migrated)")
|
||||
}
|
||||
|
||||
// Name and product
|
||||
if liveInfo.Name != "" && device.Name == liveInfo.Name {
|
||||
expectedProduct := liveInfo.Type + " " + liveInfo.ModuleType
|
||||
if device.ProductCode == expectedProduct || device.ProductCode == liveInfo.Type {
|
||||
reasons = append(reasons, "Name and product match exactly")
|
||||
} else if strings.Contains(device.ProductCode, liveInfo.Type) || strings.Contains(expectedProduct, device.ProductCode) {
|
||||
reasons = append(reasons, "Name and product similar")
|
||||
}
|
||||
}
|
||||
|
||||
// Component serials
|
||||
for _, comp := range liveInfo.Components {
|
||||
if comp.SerialNumber != "" && device.ProductSerialNumber == comp.SerialNumber {
|
||||
reasons = append(reasons, fmt.Sprintf("Product serial matches %s component", comp.Category))
|
||||
}
|
||||
}
|
||||
|
||||
return reasons
|
||||
}
|
||||
|
||||
// SimulateFullMigration simulates what would happen if migration ran for this device
|
||||
func (d *DeviceMigrationDiagnostic) SimulateFullMigration(deviceIP string) error {
|
||||
log.Printf("=== Migration Simulation for %s ===", deviceIP)
|
||||
|
||||
// Fetch device info
|
||||
liveInfo, err := d.server.sm.GetLiveDeviceInfo(deviceIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot fetch device info: %w", err)
|
||||
}
|
||||
|
||||
// Simulate discovery
|
||||
discovery := models.DiscoveredDevice{
|
||||
Host: deviceIP,
|
||||
Name: "Simulated Discovery",
|
||||
SerialNo: "",
|
||||
DiscoveryMethod: "Manual",
|
||||
}
|
||||
|
||||
log.Printf("Target Device ID: %s", liveInfo.DeviceID)
|
||||
log.Printf("Target Account: %s", liveInfo.MargeAccountUUID)
|
||||
|
||||
// Find existing variants
|
||||
existingDevices := d.server.findAllExistingDeviceVariants(discovery, liveInfo)
|
||||
|
||||
if len(existingDevices) == 0 {
|
||||
log.Printf("✨ This would be a NEW device:")
|
||||
log.Printf(" Directory: accounts/%s/devices/%s/", liveInfo.MargeAccountUUID, liveInfo.DeviceID)
|
||||
} else {
|
||||
log.Printf("🔄 This would MIGRATE %d existing device(s):", len(existingDevices))
|
||||
|
||||
for i := range existingDevices {
|
||||
existing := &existingDevices[i]
|
||||
if existing.DeviceID != liveInfo.DeviceID {
|
||||
log.Printf(" %s → %s", existing.DeviceID, liveInfo.DeviceID)
|
||||
log.Printf(" From: accounts/%s/devices/%s/", existing.AccountID, existing.DeviceID)
|
||||
log.Printf(" To: accounts/%s/devices/%s/", liveInfo.MargeAccountUUID, liveInfo.DeviceID)
|
||||
} else {
|
||||
log.Printf(" %s (already correct)", existing.DeviceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("=== End Simulation ===")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AnalyzeExistingDevices provides an overview of all devices and potential migration issues
|
||||
func (d *DeviceMigrationDiagnostic) AnalyzeExistingDevices() error {
|
||||
log.Printf("=== Device Migration Analysis ===")
|
||||
|
||||
allDevices, err := d.server.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list devices: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("📊 Total devices in datastore: %d", len(allDevices))
|
||||
|
||||
// Categorize devices
|
||||
var (
|
||||
macBasedDevices []models.ServiceDeviceInfo
|
||||
ipBasedDevices []models.ServiceDeviceInfo
|
||||
serialBasedDevices []models.ServiceDeviceInfo
|
||||
unknownDevices []models.ServiceDeviceInfo
|
||||
)
|
||||
|
||||
for i := range allDevices {
|
||||
device := &allDevices[i]
|
||||
|
||||
deviceID := device.DeviceID
|
||||
switch {
|
||||
case isMACAddress(deviceID):
|
||||
macBasedDevices = append(macBasedDevices, *device)
|
||||
case isIPAddress(deviceID):
|
||||
ipBasedDevices = append(ipBasedDevices, *device)
|
||||
case isSerialNumber(deviceID):
|
||||
serialBasedDevices = append(serialBasedDevices, *device)
|
||||
default:
|
||||
unknownDevices = append(unknownDevices, *device)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("\n📋 Device ID Categories:")
|
||||
log.Printf(" ✅ MAC-based: %d (target format)", len(macBasedDevices))
|
||||
log.Printf(" 🔄 IP-based: %d (needs migration)", len(ipBasedDevices))
|
||||
log.Printf(" 🔄 Serial-based: %d (needs migration)", len(serialBasedDevices))
|
||||
log.Printf(" ❓ Unknown format: %d", len(unknownDevices))
|
||||
|
||||
if len(ipBasedDevices) > 0 {
|
||||
log.Printf("\n🔄 IP-based devices (migration candidates):")
|
||||
|
||||
for i := range ipBasedDevices {
|
||||
device := &ipBasedDevices[i]
|
||||
log.Printf(" %s (%s)", device.DeviceID, device.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(serialBasedDevices) > 0 {
|
||||
log.Printf("\n🔄 Serial-based devices (migration candidates):")
|
||||
|
||||
for i := range serialBasedDevices {
|
||||
device := &serialBasedDevices[i]
|
||||
log.Printf(" %s (%s)", device.DeviceID, device.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(unknownDevices) > 0 {
|
||||
log.Printf("\n❓ Unknown format devices:")
|
||||
|
||||
for i := range unknownDevices {
|
||||
device := &unknownDevices[i]
|
||||
log.Printf(" %s (%s)", device.DeviceID, device.Name)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("=== End Analysis ===")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func isMACAddress(s string) bool {
|
||||
// AABBCCDDEEFF format
|
||||
if len(s) == 12 {
|
||||
return isHexOnly(s)
|
||||
}
|
||||
|
||||
// AA:BB:CC:DD:EE:FF or AA-BB-CC-DD-EE-FF format
|
||||
if len(s) == 17 && (strings.Contains(s, ":") || strings.Contains(s, "-")) {
|
||||
s = strings.ReplaceAll(s, "-", ":")
|
||||
|
||||
parts := strings.Split(s, ":")
|
||||
if len(parts) != 6 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, part := range parts {
|
||||
if len(part) != 2 || !isHexOnly(part) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isHexOnly(s string) bool {
|
||||
for _, r := range s {
|
||||
if (r < '0' || r > '9') && (r < 'A' || r > 'F') && (r < 'a' || r > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func isIPAddress(s string) bool {
|
||||
parts := strings.Split(s, ".")
|
||||
if len(parts) != 4 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, part := range parts {
|
||||
if len(part) == 0 || len(part) > 3 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, r := range part {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func isSerialNumber(s string) bool {
|
||||
// Heuristic: serial numbers are typically alphanumeric and longer than MAC addresses
|
||||
if len(s) < 10 || len(s) > 30 {
|
||||
return false
|
||||
}
|
||||
|
||||
hasLetter := false
|
||||
hasDigit := false
|
||||
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z'):
|
||||
hasLetter = true
|
||||
case r >= '0' && r <= '9':
|
||||
hasDigit = true
|
||||
default:
|
||||
return false // Contains non-alphanumeric characters
|
||||
}
|
||||
}
|
||||
|
||||
return hasLetter && hasDigit
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MirrorMiddleware returns a middleware that mirrors specific requests to the Bose upstream.
|
||||
func (s *Server) MirrorMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
enabled := s.mirrorEnabled
|
||||
endpoints := s.mirrorEndpoints
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !enabled || len(endpoints) == 0 {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
shouldMirror := false
|
||||
|
||||
for _, pattern := range endpoints {
|
||||
if matchPattern(pattern, r.URL.Path) {
|
||||
shouldMirror = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !shouldMirror {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Buffer request body for both local and mirror
|
||||
var bodyBytes []byte
|
||||
if r.Body != nil {
|
||||
bodyBytes, _ = io.ReadAll(r.Body)
|
||||
_ = r.Body.Close()
|
||||
}
|
||||
|
||||
// Prepare local request
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
|
||||
// Wrap response writer to capture local response for parity check
|
||||
localRecorder := &mirrorResponseRecorder{
|
||||
headers: make(http.Header),
|
||||
body: &bytes.Buffer{},
|
||||
}
|
||||
|
||||
// Use a multi-writer if RecordMiddleware isn't already doing this,
|
||||
// but let's just wrap it.
|
||||
|
||||
wrappedWriter := &parityResponseWriter{
|
||||
ResponseWriter: w,
|
||||
recorder: localRecorder,
|
||||
}
|
||||
|
||||
if r.Method == http.MethodGet {
|
||||
// GET: Local is primary, Mirror is asynchronous
|
||||
log.Printf("[MIRROR] Mirroring GET %s asynchronously", r.URL.Path)
|
||||
|
||||
// We need a clone for the async call, detached from original request context
|
||||
// We use context.Background() because the original request's context
|
||||
// will be canceled as soon as the local handler finishes and returns
|
||||
// the response to the speaker.
|
||||
//nolint:contextcheck
|
||||
rMirror := r.Clone(context.Background())
|
||||
rMirror.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
|
||||
// For GET, we run mirror in background and don't wait for parity in real-time
|
||||
// or we can wait for local to finish then trigger parity asynchronously.
|
||||
|
||||
next.ServeHTTP(wrappedWriter, r)
|
||||
|
||||
go func() {
|
||||
mirrorRes := s.performMirror(rMirror)
|
||||
s.checkParity(r, localRecorder, mirrorRes)
|
||||
}()
|
||||
} else {
|
||||
// POST/PUT/DELETE: Local is primary for speaker response, but we sync synchronously
|
||||
log.Printf("[MIRROR] Mirroring %s %s synchronously", r.Method, r.URL.Path)
|
||||
|
||||
// We need a clone for the background sync call
|
||||
//nolint:contextcheck
|
||||
rMirror := r.Clone(context.Background())
|
||||
rMirror.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
|
||||
next.ServeHTTP(wrappedWriter, r)
|
||||
|
||||
go func() {
|
||||
mirrorRes := s.performMirror(rMirror)
|
||||
s.checkParity(r, localRecorder, mirrorRes)
|
||||
}()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type parityResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
recorder *mirrorResponseRecorder
|
||||
}
|
||||
|
||||
func (p *parityResponseWriter) Header() http.Header {
|
||||
return p.ResponseWriter.Header()
|
||||
}
|
||||
|
||||
func (p *parityResponseWriter) Write(b []byte) (int, error) {
|
||||
p.recorder.body.Write(b)
|
||||
return p.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (p *parityResponseWriter) WriteHeader(statusCode int) {
|
||||
p.recorder.status = statusCode
|
||||
p.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func (s *Server) performMirror(r *http.Request) *mirrorResponseRecorder {
|
||||
host := r.Host
|
||||
if host == "" || host == "localhost" || strings.HasPrefix(host, "127.0.0.1") {
|
||||
host = "streaming.bose.com"
|
||||
}
|
||||
|
||||
scheme := "https"
|
||||
targetURL := scheme + "://" + host
|
||||
|
||||
target, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
log.Printf("[MIRROR_ERR] Failed to parse target URL %s: %v", targetURL, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a proxy that doesn't write to the original ResponseWriter
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
proxy.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
|
||||
// Record the mirrored request
|
||||
originalDirector := proxy.Director
|
||||
proxy.Director = func(req *http.Request) {
|
||||
originalDirector(req)
|
||||
req.Host = target.Host
|
||||
req.Header.Set("X-Mirror-Request", "true")
|
||||
}
|
||||
|
||||
// Capture response for parity check and recording
|
||||
recorder := &mirrorResponseRecorder{
|
||||
headers: make(http.Header),
|
||||
body: &bytes.Buffer{},
|
||||
}
|
||||
|
||||
proxy.ModifyResponse = func(res *http.Response) error {
|
||||
res.Header.Set("X-Proxy-Origin", "upstream-mirror")
|
||||
|
||||
// Record mirrored interaction
|
||||
if s.recorder != nil && s.recordEnabled {
|
||||
_ = s.recorder.Record("mirror", r, res)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// We use a dummy ResponseWriter to capture the results
|
||||
proxy.ServeHTTP(recorder, r)
|
||||
|
||||
log.Printf("[MIRROR] Mirror completed for %s with status %d", r.URL.Path, recorder.status)
|
||||
|
||||
return recorder
|
||||
}
|
||||
|
||||
func (s *Server) checkParity(req *http.Request, local, upstream *mirrorResponseRecorder) {
|
||||
if local.status == 0 {
|
||||
local.status = 200
|
||||
}
|
||||
|
||||
if upstream.status == 0 {
|
||||
upstream.status = 200
|
||||
}
|
||||
|
||||
mismatch := false
|
||||
reasons := []string{}
|
||||
|
||||
if local.status != upstream.status {
|
||||
mismatch = true
|
||||
|
||||
reasons = append(reasons, fmt.Sprintf("Status mismatch: local %d, upstream %d", local.status, upstream.status))
|
||||
}
|
||||
|
||||
// Compare Content-Type
|
||||
localCT := local.headers.Get("Content-Type")
|
||||
|
||||
upstreamCT := upstream.headers.Get("Content-Type")
|
||||
if localCT != upstreamCT {
|
||||
mismatch = true
|
||||
|
||||
reasons = append(reasons, fmt.Sprintf("Content-Type mismatch: local %s, upstream %s", localCT, upstreamCT))
|
||||
}
|
||||
|
||||
// Basic body comparison (could be improved with XML semantic diff)
|
||||
if !bytes.Equal(local.body.Bytes(), upstream.body.Bytes()) {
|
||||
mismatch = true
|
||||
|
||||
reasons = append(reasons, "Body content mismatch")
|
||||
}
|
||||
|
||||
if mismatch {
|
||||
log.Printf("[PARITY] Mismatch detected for %s %s: %v", req.Method, req.URL.Path, reasons)
|
||||
s.saveParityMismatch(req, local, upstream, reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) saveParityMismatch(req *http.Request, local, upstream *mirrorResponseRecorder, reasons []string) {
|
||||
record := map[string]interface{}{
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
"method": req.Method,
|
||||
"path": req.URL.Path,
|
||||
"reasons": reasons,
|
||||
"local": map[string]interface{}{
|
||||
"status": local.status,
|
||||
"body": local.body.String(),
|
||||
},
|
||||
"upstream": map[string]interface{}{
|
||||
"status": upstream.status,
|
||||
"body": upstream.body.String(),
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(record, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("[PARITY_ERR] Failed to marshal parity record: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
dir := filepath.Join(s.ds.DataDir, "parity_mismatches")
|
||||
_ = os.MkdirAll(dir, 0755)
|
||||
|
||||
filename := fmt.Sprintf("%d_%s.json", time.Now().Unix(), strings.ReplaceAll(req.URL.Path, "/", "_"))
|
||||
_ = os.WriteFile(filepath.Join(dir, filename), data, 0644)
|
||||
}
|
||||
|
||||
type mirrorResponseRecorder struct {
|
||||
status int
|
||||
headers http.Header
|
||||
body *bytes.Buffer
|
||||
}
|
||||
|
||||
func (m *mirrorResponseRecorder) Header() http.Header {
|
||||
return m.headers
|
||||
}
|
||||
|
||||
func (m *mirrorResponseRecorder) Write(b []byte) (int, error) {
|
||||
return m.body.Write(b)
|
||||
}
|
||||
|
||||
func (m *mirrorResponseRecorder) WriteHeader(statusCode int) {
|
||||
m.status = statusCode
|
||||
}
|
||||
|
||||
// matchPattern checks if a path matches a pattern with wildcards (*)
|
||||
func matchPattern(pattern, name string) bool {
|
||||
matched, _ := path.Match(pattern, name)
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
// Also try prefix match if pattern ends with /*
|
||||
if strings.HasSuffix(pattern, "/*") {
|
||||
prefix := strings.TrimSuffix(pattern, "/*")
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// HandleListParityMismatches returns a list of parity mismatches.
|
||||
func (s *Server) HandleListParityMismatches(w http.ResponseWriter, _ *http.Request) {
|
||||
dir := filepath.Join(s.ds.DataDir, "parity_mismatches")
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte("[]"))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var mismatches []interface{}
|
||||
|
||||
for _, file := range files {
|
||||
if !file.IsDir() && strings.HasSuffix(file.Name(), ".json") {
|
||||
data, err := os.ReadFile(filepath.Join(dir, file.Name()))
|
||||
if err == nil {
|
||||
var record interface{}
|
||||
if json.Unmarshal(data, &record) == nil {
|
||||
// Add filename as ID for downloading/deletion if needed
|
||||
if m, ok := record.(map[string]interface{}); ok {
|
||||
m["id"] = file.Name()
|
||||
mismatches = append(mismatches, m)
|
||||
} else {
|
||||
mismatches = append(mismatches, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp descending if possible
|
||||
sort.Slice(mismatches, func(i, j int) bool {
|
||||
mi, oki := mismatches[i].(map[string]interface{})
|
||||
|
||||
mj, okj := mismatches[j].(map[string]interface{})
|
||||
if oki && okj {
|
||||
ti, _ := mi["timestamp"].(string)
|
||||
tj, _ := mj["timestamp"].(string)
|
||||
|
||||
return ti > tj
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(mismatches); err != nil {
|
||||
log.Printf("[PARITY_ERR] Failed to encode mismatches: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleClearParityMismatches deletes all parity mismatch records.
|
||||
func (s *Server) HandleClearParityMismatches(w http.ResponseWriter, _ *http.Request) {
|
||||
dir := filepath.Join(s.ds.DataDir, "parity_mismatches")
|
||||
_ = os.RemoveAll(dir)
|
||||
_ = os.MkdirAll(dir, 0755)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte("{\"ok\": true}"))
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
)
|
||||
|
||||
func TestMirroring(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-mirror-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
// Create a mock Bose Upstream
|
||||
boseUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Only handle requests to the actual path
|
||||
if strings.HasSuffix(r.URL.Path, "/recent") {
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("<bose-response/>"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer boseUpstream.Close()
|
||||
|
||||
// Setup local server
|
||||
r, server := setupRouter("http://localhost:8001", ds)
|
||||
|
||||
// Setup recorder
|
||||
recorder := proxy.NewRecorder(tempDir)
|
||||
server.SetRecorder(recorder)
|
||||
server.SetRecordEnabled(true)
|
||||
server.SetMirrorSettings(true, []string{"/streaming/account/*/device/*/recent"})
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
account := "123"
|
||||
deviceID := "DEV1"
|
||||
|
||||
// Ensure the datastore has the necessary directories for the local handler
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
|
||||
_ = os.MkdirAll(deviceDir, 0755)
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte("<recents/>"), 0644)
|
||||
_ = os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte("<sources/>"), 0644)
|
||||
|
||||
t.Run("Mirrored Endpoint", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/streaming/account/"+account+"/device/"+deviceID+"/recent", nil)
|
||||
// We set the host to our mock upstream so performMirror finds it
|
||||
req.Host = strings.TrimPrefix(boseUpstream.URL, "http://")
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
|
||||
// Wait a bit for the async mirror to complete and be recorded
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Check if the interaction was recorded twice
|
||||
// Category: self
|
||||
matchesSelf, _ := filepath.Glob(filepath.Join(tempDir, "interactions", "*", "self", "*", "*"))
|
||||
if len(matchesSelf) == 0 {
|
||||
// List directory for debugging
|
||||
files, _ := filepath.Glob(filepath.Join(tempDir, "interactions", "*", "*", "*", "*"))
|
||||
t.Errorf("Expected to find local interaction in logs (category: self). Found: %v", files)
|
||||
}
|
||||
|
||||
// Category: mirror
|
||||
matchesMirror, _ := filepath.Glob(filepath.Join(tempDir, "interactions", "*", "mirror", "*", "*"))
|
||||
if len(matchesMirror) == 0 {
|
||||
// List directory for debugging
|
||||
files, _ := filepath.Glob(filepath.Join(tempDir, "interactions", "*", "*", "*", "*"))
|
||||
t.Errorf("Expected to find mirrored interaction in logs (category: mirror). Found: %v", files)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// SetRecordEnabled is a helper for testing
|
||||
func (s *Server) SetRecordEnabled(enabled bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.recordEnabled = enabled
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
@@ -12,22 +13,11 @@ import (
|
||||
// RecordMiddleware returns a middleware that records "self" requests and responses.
|
||||
func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.recorder == nil || !s.recordEnabled {
|
||||
if s.recorder == nil || !s.recordEnabled || r.Header.Get("Upgrade") == "websocket" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
internalPaths := s.internalPaths
|
||||
s.mu.RUnlock()
|
||||
|
||||
for _, pattern := range internalPaths {
|
||||
if matchPattern(pattern, r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Buffer the request body if it exists
|
||||
var reqBody []byte
|
||||
|
||||
@@ -50,6 +40,10 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
// Create a response object for the recorder
|
||||
res := rw.getRecordedResponse(r)
|
||||
if res.StatusCode >= 400 {
|
||||
log.Printf("[DEBUG_LOG] Recording error response: %d %s %s", res.StatusCode, r.Method, r.URL.Path)
|
||||
}
|
||||
|
||||
if res.Body != nil {
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
}
|
||||
@@ -63,8 +57,9 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
body *bytes.Buffer
|
||||
statusCode int
|
||||
body *bytes.Buffer
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Header() http.Header {
|
||||
@@ -72,18 +67,28 @@ func (rw *responseWriter) Header() http.Header {
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
if rw.wroteHeader {
|
||||
return
|
||||
}
|
||||
|
||||
rw.statusCode = code
|
||||
rw.wroteHeader = true
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||
if !rw.wroteHeader {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
rw.body.Write(b)
|
||||
|
||||
return rw.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) getRecordedResponse(r *http.Request) *http.Response {
|
||||
statusCode := rw.statusCode
|
||||
if statusCode == 0 {
|
||||
if !rw.wroteHeader && statusCode == 0 {
|
||||
statusCode = http.StatusOK
|
||||
}
|
||||
|
||||
|
||||
+76
-405
@@ -3,30 +3,24 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/migration"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// Server handles HTTP requests for the SoundTouch service.
|
||||
type Server struct {
|
||||
ds *datastore.DataStore
|
||||
sm *setup.Manager
|
||||
migrationManager *migration.Manager
|
||||
mu sync.RWMutex
|
||||
serverURL string
|
||||
soundcorkURL string
|
||||
@@ -38,11 +32,8 @@ type Server struct {
|
||||
discoveryInterval time.Duration
|
||||
discoveryEnabled bool
|
||||
dnsEnabled bool
|
||||
dnsUpstream []string
|
||||
dnsUpstream string
|
||||
dnsBindAddr string
|
||||
mirrorEnabled bool
|
||||
mirrorEndpoints []string
|
||||
internalPaths []string
|
||||
enableSoundcorkProxy bool
|
||||
shortcuts map[string]int
|
||||
recorder *proxy.Recorder
|
||||
@@ -56,21 +47,15 @@ type Server struct {
|
||||
spotifyClientID string
|
||||
spotifyClientSecret string
|
||||
spotifyRedirectURI string
|
||||
baseURL string
|
||||
spotifyService *spotify.Service
|
||||
}
|
||||
|
||||
// NewServer creates a new SoundTouch service server.
|
||||
func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, proxyRedact, proxyLogBody, recordEnabled, enableSoundcorkProxy, migrationEnabled, migrationDryRun bool) *Server {
|
||||
// Initialize migration manager
|
||||
migrationConfig := migration.Config{
|
||||
Enabled: migrationEnabled,
|
||||
DryRun: migrationDryRun,
|
||||
}
|
||||
|
||||
func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, proxyRedact, proxyLogBody, recordEnabled, enableSoundcorkProxy bool) *Server {
|
||||
s := &Server{
|
||||
ds: ds,
|
||||
sm: sm,
|
||||
migrationManager: migration.NewManager(ds, migrationConfig),
|
||||
serverURL: serverURL,
|
||||
soundcorkURL: "http://localhost:8001",
|
||||
proxyRedact: proxyRedact,
|
||||
@@ -78,7 +63,6 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, pro
|
||||
recordEnabled: recordEnabled,
|
||||
enableSoundcorkProxy: enableSoundcorkProxy,
|
||||
discoveryInterval: 5 * time.Minute,
|
||||
discoveryEnabled: true,
|
||||
}
|
||||
|
||||
return s
|
||||
@@ -103,47 +87,6 @@ func (s *Server) SetDiscoverySettings(interval time.Duration, enabled bool) {
|
||||
s.discoveryEnabled = enabled
|
||||
}
|
||||
|
||||
// parseUpstreamDNS splits a comma-separated string of DNS servers.
|
||||
func parseUpstreamDNS(upstream string) []string {
|
||||
var upstreamList []string
|
||||
|
||||
if upstream != "" {
|
||||
for _, u := range strings.Split(upstream, ",") {
|
||||
u = strings.TrimSpace(u)
|
||||
if u != "" {
|
||||
upstreamList = append(upstreamList, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return upstreamList
|
||||
}
|
||||
|
||||
// getSystemDNS returns the DNS servers from /etc/resolv.conf.
|
||||
func getSystemDNS() []string {
|
||||
config, _ := dns.ClientConfigFromFile("/etc/resolv.conf")
|
||||
if config != nil && len(config.Servers) > 0 {
|
||||
return config.Servers
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// areUpstreamsEqual compares two slices of DNS server addresses.
|
||||
func areUpstreamsEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// SetDNSSettings sets the DNS discovery settings for the server.
|
||||
func (s *Server) SetDNSSettings(enabled bool, upstream, bind string) {
|
||||
s.mu.Lock()
|
||||
@@ -153,23 +96,11 @@ func (s *Server) SetDNSSettings(enabled bool, upstream, bind string) {
|
||||
oldUpstream := s.dnsUpstream
|
||||
|
||||
s.dnsEnabled = enabled
|
||||
s.dnsUpstream = upstream
|
||||
s.dnsBindAddr = bind
|
||||
|
||||
upstreamList := parseUpstreamDNS(upstream)
|
||||
|
||||
// Try to get system DNS if none provided
|
||||
if enabled && len(upstreamList) == 0 {
|
||||
upstreamList = getSystemDNS()
|
||||
if len(upstreamList) > 0 {
|
||||
log.Printf("[DNS] Using system DNS servers from /etc/resolv.conf: %v", upstreamList)
|
||||
}
|
||||
}
|
||||
|
||||
s.dnsUpstream = upstreamList
|
||||
upstreamChanged := !areUpstreamsEqual(upstreamList, oldUpstream)
|
||||
|
||||
if s.dnsDiscovery != nil {
|
||||
if !enabled || bind != oldBind || upstreamChanged {
|
||||
if !enabled || bind != oldBind || upstream != oldUpstream {
|
||||
log.Printf("[DNS] Settings changed, stopping DNS discovery server")
|
||||
|
||||
_ = s.dnsDiscovery.Shutdown()
|
||||
@@ -177,8 +108,8 @@ func (s *Server) SetDNSSettings(enabled bool, upstream, bind string) {
|
||||
}
|
||||
}
|
||||
|
||||
if enabled && len(upstreamList) == 0 {
|
||||
log.Printf("[DNS] Cannot start DNS discovery server: upstream DNS is empty and no system DNS found")
|
||||
if enabled && upstream == "" {
|
||||
log.Printf("[DNS] Cannot start DNS discovery server: upstream DNS is empty")
|
||||
|
||||
s.dnsEnabled = false
|
||||
|
||||
@@ -186,30 +117,26 @@ func (s *Server) SetDNSSettings(enabled bool, upstream, bind string) {
|
||||
}
|
||||
|
||||
if enabled && s.dnsDiscovery == nil {
|
||||
s.startDNSDiscovery(bind, upstreamList)
|
||||
}
|
||||
}
|
||||
log.Printf("[DNS] Starting DNS discovery server on %s", bind)
|
||||
|
||||
func (s *Server) startDNSDiscovery(bind string, upstreamList []string) {
|
||||
log.Printf("[DNS] Starting DNS discovery server on %s", bind)
|
||||
u, _ := url.Parse(s.serverURL)
|
||||
|
||||
u, _ := url.Parse(s.serverURL)
|
||||
|
||||
serviceIP := u.Hostname()
|
||||
if serviceIP == "localhost" || serviceIP == "" {
|
||||
serviceIP = "127.0.0.1"
|
||||
}
|
||||
|
||||
if s.sm != nil {
|
||||
serviceIP = s.sm.GetResolvedIP(serviceIP)
|
||||
}
|
||||
|
||||
s.dnsDiscovery = discovery.NewDNSDiscovery(upstreamList, serviceIP)
|
||||
go func(d *discovery.DNSDiscovery, addr string) {
|
||||
if err := d.Start(addr); err != nil {
|
||||
log.Printf("Warning: DNS discovery server error: %v", err)
|
||||
serviceIP := u.Hostname()
|
||||
if serviceIP == "localhost" || serviceIP == "" {
|
||||
serviceIP = "127.0.0.1"
|
||||
}
|
||||
}(s.dnsDiscovery, bind)
|
||||
|
||||
if s.sm != nil {
|
||||
serviceIP = s.sm.GetResolvedIP(serviceIP)
|
||||
}
|
||||
|
||||
s.dnsDiscovery = discovery.NewDNSDiscovery(upstream, serviceIP)
|
||||
go func(d *discovery.DNSDiscovery, addr string) {
|
||||
if err := d.Start(addr); err != nil {
|
||||
log.Printf("Warning: DNS discovery server error: %v", err)
|
||||
}
|
||||
}(s.dnsDiscovery, bind)
|
||||
}
|
||||
}
|
||||
|
||||
// GetDNSRunning returns whether DNS discovery is active and its bind address.
|
||||
@@ -316,21 +243,12 @@ func (s *Server) SetMgmtConfig(username, password string) {
|
||||
s.mgmtPassword = password
|
||||
}
|
||||
|
||||
// SetMirrorSettings sets the mirroring settings for the server.
|
||||
func (s *Server) SetMirrorSettings(enabled bool, endpoints []string) {
|
||||
// SetBaseURL sets the external base URL for OAuth callbacks.
|
||||
func (s *Server) SetBaseURL(baseURL string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.mirrorEnabled = enabled
|
||||
s.mirrorEndpoints = endpoints
|
||||
}
|
||||
|
||||
// SetInternalPaths sets the internal paths for the server.
|
||||
func (s *Server) SetInternalPaths(paths []string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.internalPaths = paths
|
||||
s.baseURL = baseURL
|
||||
}
|
||||
|
||||
// SetSpotifyService sets the Spotify OAuth service.
|
||||
@@ -357,14 +275,6 @@ func (s *Server) GetSettings() (string, string, string) {
|
||||
return s.serverURL, s.soundcorkURL, s.httpsServerURL
|
||||
}
|
||||
|
||||
// IsSpotifyConfigured returns whether Spotify integration is configured.
|
||||
func (s *Server) IsSpotifyConfigured() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.spotifyService != nil
|
||||
}
|
||||
|
||||
// GetProxySettings returns the current proxy settings.
|
||||
func (s *Server) GetProxySettings() (bool, bool, bool, bool) {
|
||||
s.mu.RLock()
|
||||
@@ -408,197 +318,51 @@ func (s *Server) DiscoverDevices(ctx context.Context) {
|
||||
s.mergeOverlappingDevices()
|
||||
}
|
||||
|
||||
// findExistingDeviceInfoByDeviceID looks for existing device info by deviceID
|
||||
func (s *Server) findExistingDeviceInfoByDeviceID(deviceID string) *models.ServiceDeviceInfo {
|
||||
allDevices, err := s.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := range allDevices {
|
||||
device := &allDevices[i]
|
||||
if device.DeviceID == deviceID {
|
||||
return device
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrimeDeviceWithSpotify triggers a Spotify priming of the speaker if a Spotify account is linked.
|
||||
func (s *Server) PrimeDeviceWithSpotify(deviceIP string) {
|
||||
s.mu.RLock()
|
||||
svc := s.spotifyService
|
||||
s.mu.RUnlock()
|
||||
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
accounts := svc.GetAccounts()
|
||||
if len(accounts) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// We'll use the first linked account. In the future, we might want to let the user
|
||||
// pick or map accounts to speakers, but for now, we follow the "One linked account" model.
|
||||
accessToken, username, err := svc.GetFreshToken()
|
||||
if err != nil {
|
||||
log.Printf("[Spotify Watchdog] Failed to get fresh token for %s: %v", deviceIP, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[Spotify Watchdog] Proactively priming %s with Spotify user %s", deviceIP, username)
|
||||
|
||||
if err := s.pushSpotifyTokenToDevice(deviceIP, username, accessToken); err != nil {
|
||||
log.Printf("[Spotify Watchdog] Failed to prime %s: %v", deviceIP, err)
|
||||
} else {
|
||||
log.Printf("[Spotify Watchdog] Successfully primed %s", deviceIP)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) pushSpotifyTokenToDevice(deviceIP, username, accessToken string) error {
|
||||
// ZeroConf API endpoint on the speaker
|
||||
var zcURL string
|
||||
if _, _, err := net.SplitHostPort(deviceIP); err == nil {
|
||||
// If port is specified (e.g. in tests), keep it but usually it's just IP
|
||||
zcURL = fmt.Sprintf("http://%s/zc", deviceIP)
|
||||
} else {
|
||||
// If no port specified, default to 8200
|
||||
zcURL = fmt.Sprintf("http://%s:8200/zc", deviceIP)
|
||||
}
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("action", "addUser")
|
||||
data.Set("userName", username)
|
||||
data.Set("blob", accessToken)
|
||||
data.Set("clientKey", "")
|
||||
data.Set("tokenType", "accesstoken")
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.PostForm(zcURL, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("POST to %s failed: %w", zcURL, err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("POST to %s returned status %d: %s", zcURL, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) {
|
||||
log.Printf("Discovered Bose device: %s at %s (Serial: %s)", d.Name, d.Host, d.SerialNo)
|
||||
|
||||
// 1. Always fetch live device info from /info endpoint as the authoritative source
|
||||
liveInfo, err := s.sm.GetLiveDeviceInfo(d.Host)
|
||||
if err != nil {
|
||||
log.Printf("Failed to fetch live device info for %s at %s: %v", d.Name, d.Host, err)
|
||||
// Fallback to discovery info if /info is not available
|
||||
s.handleDiscoveredDeviceFallback(d)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Use deviceID from /info as the canonical device identifier
|
||||
deviceID := liveInfo.DeviceID
|
||||
if deviceID == "" {
|
||||
log.Printf("No deviceID found in /info response for %s at %s, using fallback", d.Name, d.Host)
|
||||
s.handleDiscoveredDeviceFallback(d)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Using deviceID '%s' from /info for device %s at %s", deviceID, d.Name, d.Host)
|
||||
|
||||
// 3. Get account ID from live info or fallback to existing/default
|
||||
accountID := liveInfo.MargeAccountUUID
|
||||
if accountID == "" {
|
||||
// Try to find account ID from existing device entries
|
||||
if existing := s.findExistingDeviceInfoByDeviceID(deviceID); existing != nil {
|
||||
accountID = existing.AccountID
|
||||
}
|
||||
}
|
||||
|
||||
if accountID == "" {
|
||||
accountID = "default"
|
||||
}
|
||||
|
||||
// 4. Get primary MAC address from networkInfo
|
||||
macAddress := liveInfo.GetPrimaryMacAddress()
|
||||
|
||||
// 5. Build complete device info from live data
|
||||
info := &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID, // Use deviceID from /info (MAC address)
|
||||
AccountID: accountID,
|
||||
Name: liveInfo.Name, // Use name from /info
|
||||
IPAddress: d.Host, // IP from discovery
|
||||
MacAddress: macAddress, // MAC from /info networkInfo
|
||||
DeviceSerialNumber: liveInfo.SerialNumber, // Serial from components
|
||||
ProductCode: liveInfo.Type + " " + liveInfo.ModuleType, // Type + ModuleType
|
||||
FirmwareVersion: liveInfo.SoftwareVer,
|
||||
ProductSerialNumber: "", // Will be populated from components if available
|
||||
DiscoveryMethod: d.DiscoveryMethod,
|
||||
}
|
||||
|
||||
// 6. Extract product serial number from PackagedProduct component
|
||||
for _, comp := range liveInfo.Components {
|
||||
if comp.Category == "PackagedProduct" && comp.SerialNumber != "" {
|
||||
info.ProductSerialNumber = comp.SerialNumber
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Check for existing device entries that need migration
|
||||
log.Printf("Checking for existing device variants to migrate for device %s (MAC: %s)", liveInfo.Name, deviceID)
|
||||
|
||||
existingDevices := s.findAllExistingDeviceVariants(d, liveInfo)
|
||||
if len(existingDevices) == 0 {
|
||||
log.Printf("No existing device variants found for migration")
|
||||
}
|
||||
|
||||
// Use migration manager to handle device directory migration
|
||||
migrated := s.migrationManager.MigrateDevicesIfNeeded(existingDevices, deviceID)
|
||||
if !migrated {
|
||||
log.Printf("Device %s: no migration needed (already uses correct MAC-based ID %s)", liveInfo.Name, deviceID)
|
||||
}
|
||||
|
||||
// 8. Save the updated device info
|
||||
if err := s.ds.SaveDeviceInfo(accountID, deviceID, info); err != nil {
|
||||
log.Printf("Failed to save device info for %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Successfully saved device %s (%s) with MAC-based deviceID: %s", info.Name, d.Host, deviceID)
|
||||
}
|
||||
|
||||
// GetMigrationStats returns migration statistics for debugging/monitoring
|
||||
func (s *Server) GetMigrationStats() migration.Stats {
|
||||
return s.migrationManager.GetStats()
|
||||
}
|
||||
|
||||
// handleDiscoveredDeviceFallback handles device discovery when /info endpoint is not available
|
||||
func (s *Server) handleDiscoveredDeviceFallback(d models.DiscoveredDevice) {
|
||||
log.Printf("Using fallback discovery method for device: %s at %s", d.Name, d.Host)
|
||||
|
||||
// Use discovery data as-is with the old logic
|
||||
// 1. Check if we already have this device
|
||||
existingID := s.findExistingDeviceID(d)
|
||||
|
||||
// Use SerialNo if available, otherwise fallback to IP for the datastore directory name
|
||||
if d.SerialNo == "" {
|
||||
// If serial is missing from discovery, try to fetch it from :8090/info
|
||||
log.Printf("Serial number missing for %s at %s, attempting live info fetch...", d.Name, d.Host)
|
||||
|
||||
liveInfo, err := s.sm.GetLiveDeviceInfo(d.Host)
|
||||
if err == nil && liveInfo.SerialNumber != "" {
|
||||
d.SerialNo = liveInfo.SerialNumber
|
||||
log.Printf("Successfully retrieved serial number %s for %s via live info", d.SerialNo, d.Host)
|
||||
}
|
||||
}
|
||||
|
||||
deviceID := d.SerialNo
|
||||
if deviceID == "" {
|
||||
deviceID = d.Host
|
||||
}
|
||||
|
||||
accountID := "default"
|
||||
if existing := s.findExistingDeviceInfo(d); existing != nil {
|
||||
accountID = existing.AccountID
|
||||
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{
|
||||
@@ -619,11 +383,8 @@ func (s *Server) handleDiscoveredDeviceFallback(d models.DiscoveredDevice) {
|
||||
}
|
||||
|
||||
if err := s.ds.SaveDeviceInfo(accountID, deviceID, info); err != nil {
|
||||
log.Printf("Failed to save device info for %s: %v", deviceID, err)
|
||||
return
|
||||
log.Printf("Failed to save device info: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Successfully saved device %s (%s) with fallback deviceID: %s", info.Name, d.Host, deviceID)
|
||||
}
|
||||
|
||||
func (s *Server) mergeOverlappingDevices() {
|
||||
@@ -703,99 +464,6 @@ func (s *Server) findExistingDeviceID(d models.DiscoveredDevice) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// findAllExistingDeviceVariants finds all existing device entries that could represent the same physical device
|
||||
func (s *Server) findAllExistingDeviceVariants(d models.DiscoveredDevice, liveInfo *setup.DeviceInfoXML) []models.ServiceDeviceInfo {
|
||||
log.Printf("Searching for existing device variants with criteria:")
|
||||
log.Printf(" Discovery IP: %s", d.Host)
|
||||
log.Printf(" Discovery Serial: %s", d.SerialNo)
|
||||
log.Printf(" Live Info Serial: %s", liveInfo.SerialNumber)
|
||||
log.Printf(" Live Info Name: %s", liveInfo.Name)
|
||||
log.Printf(" Live Info MAC: %s", liveInfo.GetPrimaryMacAddress())
|
||||
log.Printf(" Live Info Product: %s %s", liveInfo.Type, liveInfo.ModuleType)
|
||||
|
||||
allDevices, err := s.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var matches []models.ServiceDeviceInfo
|
||||
|
||||
seenDeviceIDs := make(map[string]bool)
|
||||
|
||||
for i := range allDevices {
|
||||
device := &allDevices[i]
|
||||
if seenDeviceIDs[device.DeviceID] {
|
||||
continue
|
||||
}
|
||||
|
||||
matchReason := s.getMatchReason(*device, d, liveInfo)
|
||||
if matchReason != "" {
|
||||
matches = append(matches, *device)
|
||||
seenDeviceIDs[device.DeviceID] = true
|
||||
log.Printf(" ✓ Found variant %s: %s", device.DeviceID, matchReason)
|
||||
}
|
||||
}
|
||||
|
||||
if len(matches) == 0 {
|
||||
log.Printf(" No existing device variants found")
|
||||
} else {
|
||||
log.Printf("Found %d existing device variant(s) for %s:", len(matches), liveInfo.Name)
|
||||
|
||||
for i := range matches {
|
||||
match := &matches[i]
|
||||
log.Printf(" - %s (Account: %s, IP: %s, Serial: %s, MAC: %s, Product: %s)",
|
||||
match.DeviceID, match.AccountID, match.IPAddress, match.DeviceSerialNumber, match.MacAddress, match.ProductCode)
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
func (s *Server) getMatchReason(device models.ServiceDeviceInfo, d models.DiscoveredDevice, liveInfo *setup.DeviceInfoXML) string {
|
||||
// 1. Same IP address
|
||||
if d.Host != "" && device.IPAddress == d.Host {
|
||||
return fmt.Sprintf("IP address match (%s == %s)", d.Host, device.IPAddress)
|
||||
}
|
||||
|
||||
// 2. Same UPnP serial number
|
||||
if d.SerialNo != "" && (device.DeviceID == d.SerialNo || device.DeviceSerialNumber == d.SerialNo) {
|
||||
if device.DeviceID == d.SerialNo {
|
||||
return "UPnP serial as DeviceID"
|
||||
}
|
||||
|
||||
return "UPnP serial in DeviceSerialNumber"
|
||||
}
|
||||
|
||||
// 3. Same device serial number from /info
|
||||
if liveInfo.SerialNumber != "" && device.DeviceSerialNumber == liveInfo.SerialNumber {
|
||||
return fmt.Sprintf("device serial number match (%s)", liveInfo.SerialNumber)
|
||||
}
|
||||
|
||||
// 4. Same MAC address (if device already has one stored)
|
||||
primaryMAC := liveInfo.GetPrimaryMacAddress()
|
||||
if primaryMAC != "" && device.MacAddress == primaryMAC {
|
||||
return fmt.Sprintf("MAC address match (%s)", primaryMAC)
|
||||
}
|
||||
|
||||
// 5. Same device name and similar product (fuzzy match for renamed devices)
|
||||
if liveInfo.Name != "" && device.Name == liveInfo.Name {
|
||||
expectedProduct := liveInfo.Type + " " + liveInfo.ModuleType
|
||||
if device.ProductCode == expectedProduct ||
|
||||
device.ProductCode == liveInfo.Type ||
|
||||
strings.Contains(device.ProductCode, liveInfo.Type) ||
|
||||
strings.Contains(expectedProduct, device.ProductCode) {
|
||||
return fmt.Sprintf("name and product match (name: %s, product: %s)", liveInfo.Name, device.ProductCode)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. DeviceID matches component serial (device was stored by serial before)
|
||||
if liveInfo.SerialNumber != "" && device.DeviceID == liveInfo.SerialNumber {
|
||||
return fmt.Sprintf("DeviceID matches component serial (%s)", liveInfo.SerialNumber)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) findExistingDeviceInfo(d models.DiscoveredDevice) *models.ServiceDeviceInfo {
|
||||
allDevices, _ := s.ds.ListAllDevices()
|
||||
for i := range allDevices {
|
||||
@@ -813,19 +481,22 @@ func (s *Server) findExistingDeviceInfo(d models.DiscoveredDevice) *models.Servi
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) resolveDeviceIDToIP(deviceID string) (string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
// 1. Try to find in Datastore
|
||||
// lookupIP resolves a deviceId to its last known device IP.
|
||||
func (s *Server) lookupIP(deviceId string) (string, error) {
|
||||
devices, err := s.ds.ListAllDevices()
|
||||
if err == nil {
|
||||
for i := range devices {
|
||||
if devices[i].DeviceID == deviceID {
|
||||
return devices[i].IPAddress, nil
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for i := range devices {
|
||||
if devices[i].DeviceID == deviceId {
|
||||
if devices[i].IPAddress == "" {
|
||||
return "", fmt.Errorf("no IP known for deviceId %s", deviceId)
|
||||
}
|
||||
|
||||
return devices[i].IPAddress, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("device not found: %s", deviceID)
|
||||
return "", fmt.Errorf("deviceId %s not found", deviceId)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestMergeOverlappingDevices(t *testing.T) {
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
s := NewServer(ds, nil, "http://localhost", false, false, false, false, false, false)
|
||||
s := &Server{ds: ds}
|
||||
|
||||
// Case 1: IP-only entry and Serial-based entry for the same IP
|
||||
ip := "192.168.1.100"
|
||||
@@ -74,7 +74,7 @@ func TestFindExistingDeviceID(t *testing.T) {
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
s := NewServer(ds, nil, "http://localhost", false, false, false, false, false, false)
|
||||
s := &Server{ds: ds}
|
||||
|
||||
ip := "192.168.1.101"
|
||||
serial := "SERIAL456"
|
||||
|
||||
@@ -2,578 +2,97 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>AfterTouch (SoundTouch Toolkit)</title>
|
||||
<title>AfterTouch - Select Interface</title>
|
||||
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/web/css/style.css">
|
||||
<link rel="stylesheet" href="/web/shared/common.css">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: #121212;
|
||||
color: #e0e0e0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
}
|
||||
h1 { color: #fff; margin-bottom: 30px; }
|
||||
.choices {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
.choice-card {
|
||||
background: #1e1e1e;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
width: 200px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all 0.3s;
|
||||
border: 2px solid transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-shadow: 0 10px 20px rgba(0,0,0,0.5);
|
||||
}
|
||||
.choice-card:hover {
|
||||
transform: translateY(-5px);
|
||||
border-color: #00bcd4;
|
||||
background: #252525;
|
||||
}
|
||||
.icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.title {
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 10px;
|
||||
color: #00bcd4;
|
||||
}
|
||||
.desc {
|
||||
font-size: 0.9rem;
|
||||
color: #888;
|
||||
}
|
||||
footer {
|
||||
margin-top: 50px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>AfterTouch</h1>
|
||||
<p style="margin-top: -10px; font-style: italic; color: #666;">Bose SoundTouch Toolkit</p>
|
||||
<div class="container">
|
||||
<h1>AfterTouch</h1>
|
||||
<p style="margin-top: -25px; font-style: italic; color: #666; margin-bottom: 30px;">Bose SoundTouch Toolkit</p>
|
||||
<p style="margin-bottom: 40px; color: #aaa;">Select an interface to continue.</p>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab-buttons">
|
||||
<button class="tab-btn active" onclick="openTab(event, 'tab-overview')">Overview</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-settings')">1. Settings</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-devices')">2. Devices</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-sync')">3. Data Sync</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-migration')">4. Migration</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-interactions')">5. Interactions & Events</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-parity')">6. Parity & Mirroring</button>
|
||||
</div>
|
||||
<div class="choices">
|
||||
<a href="/web/stockholm-mini/" class="choice-card">
|
||||
<div class="icon">📻</div>
|
||||
<div class="title">Stockholm Mini</div>
|
||||
<div class="desc">Lightweight device controller and player.</div>
|
||||
</a>
|
||||
|
||||
<!-- Tab 0: Overview -->
|
||||
<div id="tab-overview" class="tab-content active">
|
||||
<h2>Welcome to AfterTouch</h2>
|
||||
<p>This toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026. It emulates the necessary cloud services locally on your network.</p>
|
||||
|
||||
<h3>Migration Process at a Glance</h3>
|
||||
<div class="info-box prerequisite-box">
|
||||
<strong>🔌 Prerequisite: Enable SSH</strong><br>
|
||||
Migration requires SSH access. To enable it:
|
||||
<ol style="margin-top: 5px; margin-bottom: 5px;">
|
||||
<li>Create an empty file named <code>remote_services</code> on a USB stick.</li>
|
||||
<li>Insert it into the speaker's <strong>SERVICE</strong> port and reboot the speaker.</li>
|
||||
</ol>
|
||||
<strong>Verify connection:</strong>
|
||||
<ul style="margin-top: 5px; margin-bottom: 0; padding-left: 20px;">
|
||||
<li>Use the <strong>Migration</strong> tab to select your device and verify that <em>SSH Connection</em> shows ✅ Success.</li>
|
||||
<li>Or manually: <code>ssh -oHostKeyAlgorithms=+ssh-rsa root@<SPEAKER-IP></code> (no password).</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ol class="guide-steps">
|
||||
<li>
|
||||
<strong>Settings:</strong> Review the <strong>Settings</strong> tab. Ensure the "Target Domain" and "Proxy Domain" use an IP address or domain name that is <strong>accessible from your speakers</strong> (usually the IP of this server on your local network).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Discovery:</strong> Go to the <strong>Devices</strong> tab to find your speakers on the network.
|
||||
Ensure your speakers are powered on and connected to the same network.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Data Sync:</strong> In the <strong>Data Sync</strong> tab, fetch your current presets, recents, and sources.
|
||||
This step is critical to ensure your local service has all your personalized data before you disconnect from the Bose cloud.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Migration:</strong> In the <strong>Migration</strong> tab, redirect your speaker to this local service.
|
||||
We recommend the <strong>XML Configuration</strong> method as it is surgical and easily reversible.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Verification:</strong> After migration and reboot, your speaker will communicate with this toolkit instead of Bose servers.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<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.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.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>
|
||||
|
||||
<!-- Tab 1: Settings -->
|
||||
<div id="tab-settings" class="tab-content">
|
||||
<h2>System Settings</h2>
|
||||
<p style="font-size: 0.9em; color: #555; margin-bottom: 20px;">
|
||||
<strong>Note:</strong> These URLs must be <strong>accessible from your SoundTouch devices</strong>.
|
||||
Use the IP address of this server on your local network (e.g., <code>http://192.168.1.100:8000</code>)
|
||||
rather than <code>localhost</code>.
|
||||
</p>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label for="target-domain">Target Domain:</label>
|
||||
<input type="text" id="target-domain" placeholder="http://192.168.x.x:8000" style="width: 300px;">
|
||||
<span style="font-size: 0.8em; color: #666;">(Standard services URL)</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label for="soundcork-url">Soundcork URL:</label>
|
||||
<input type="text" id="soundcork-url" placeholder="http://192.168.x.x:8001" style="width: 300px;">
|
||||
<span style="font-size: 0.8em; color: #666;">(Soundcork services URL)</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label for="discovery-interval">Discovery Interval:</label>
|
||||
<input type="text" id="discovery-interval" placeholder="5m" style="width: 100px;">
|
||||
<label style="margin-left: 15px;"><input type="checkbox" id="discovery-enabled"> Enable Automated Discovery</label>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<strong>DNS Discovery:</strong>
|
||||
<div style="margin-top: 5px;">
|
||||
<label style="display: block; margin-bottom: 5px;">
|
||||
<input type="checkbox" id="dns-enabled"> Enable DNS Discovery Server
|
||||
</label>
|
||||
<div style="margin-left: 20px; margin-bottom: 5px;">
|
||||
<label for="dns-upstream">Upstream DNS:</label>
|
||||
<input type="text" id="dns-upstream" placeholder="Default: system nameservers" style="width: 200px;">
|
||||
<span class="info-toggle" onclick="toggleInfo('dns-upstream-info')">ⓘ</span>
|
||||
<div id="dns-upstream-info" class="info-details">
|
||||
Optional: comma-separated list of DNS servers (e.g., <code>1.1.1.1, 8.8.8.8</code>).<br>
|
||||
If empty, AfterTouch defaults to the system nameservers (e.g. from <code>/etc/resolv.conf</code>).<br>
|
||||
<div id="dns-current-upstream" style="margin-top: 5px; font-weight: bold;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-left: 20px;">
|
||||
<label for="dns-bind">DNS Bind Address:</label>
|
||||
<input type="text" id="dns-bind" placeholder=":53" style="width: 100px;">
|
||||
<span style="font-size: 0.8em; color: #666; margin-left: 5px;">(e.g., :53 or 0.0.0.0:53. <strong>Port 53</strong> is required for actual migration)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<strong>Endpoint Mirroring:</strong>
|
||||
<div style="margin-top: 5px;">
|
||||
<label style="display: block; margin-bottom: 5px;">
|
||||
<input type="checkbox" id="mirror-enabled"> Enable Background Mirroring to Bose Cloud
|
||||
</label>
|
||||
<div style="margin-left: 20px; margin-bottom: 5px;">
|
||||
<label for="mirror-endpoints">Mirror Endpoints (one per line, supports * wildcards):</label><br>
|
||||
<textarea id="mirror-endpoints" rows="4" style="width: 100%; max-width: 600px; margin-top: 5px; font-family: monospace;" placeholder="/streaming/account/*/device/*/recent /accounts/*/devices/*/presets/*"></textarea>
|
||||
<div class="info-box" style="margin-top: 5px; font-size: 0.85em; padding: 10px;">
|
||||
<strong>Note:</strong> Mirroring sends matching requests (including full headers) to the official Bose servers for parity comparison.
|
||||
If <em>Redact Sensitive Data</em> is enabled in Proxy Settings, credentials will be masked in <strong>logs and recordings</strong>, but
|
||||
full headers are always sent to Bose to ensure service compatibility.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<strong>Spotify Integration:</strong>
|
||||
<div id="spotify-config-status" style="margin-top: 5px; font-size: 0.9em;">
|
||||
Checking configuration...
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<strong>Proxy Logging:</strong>
|
||||
<div style="margin-top: 5px;">
|
||||
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"> Redact Sensitive Headers</label>
|
||||
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"> Log Bodies</label>
|
||||
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="enable-soundcork-proxy" onchange="updateProxySettings()"> Enable Soundcork Proxy (Legacy)</label>
|
||||
<label style="display: block; margin-bottom: 5px;">
|
||||
<input type="checkbox" id="proxy-record" onchange="updateProxySettings()"> Record Interactions
|
||||
<span style="font-size: 0.85em; color: #666; margin-left: 5px;">(View in <strong>5. Interactions</strong> tab)</span>
|
||||
</label>
|
||||
<div style="margin-left: 20px; margin-top: 10px;">
|
||||
<label for="internal-paths">Internal Paths (skip recording for these patterns):</label><br>
|
||||
<textarea id="internal-paths" rows="2" style="width: 100%; max-width: 600px; margin-top: 5px; font-family: monospace;" placeholder="/setup/* /web/*"></textarea>
|
||||
<div style="font-size: 0.8em; color: #666; margin-top: 2px;">
|
||||
Requests matching these patterns will be excluded from recording. Use one pattern per line.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<button onclick="updateSettings()">Save Settings</button>
|
||||
<span id="settings-status" style="margin-left: 10px; font-size: 0.9em;"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Devices -->
|
||||
<div id="tab-devices" class="tab-content">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h2>Known Devices <span id="discovery-indicator" style="font-size: 0.5em; vertical-align: middle; display: none;">🔍 Scanning...</span></h2>
|
||||
<div id="spotify-status-header" style="background: #f0f0f0; padding: 5px 15px; border-radius: 20px; font-size: 0.9em; display: flex; align-items: center; gap: 10px;">
|
||||
Spotify: <span id="spotify-account-name" style="font-weight: bold;">Not Linked</span>
|
||||
<button id="link-spotify-btn" onclick="linkSpotify()" style="font-size: 0.8em; padding: 2px 8px; background: #1DB954; color: white; border: none; border-radius: 10px; cursor: pointer;">Link Account</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="device-list">Loading devices...</div>
|
||||
<div style="margin-top: 20px;">
|
||||
<button onclick="triggerDiscovery()">Scan Again</button>
|
||||
<input type="text" id="add-manual-ip" placeholder="Manual IP (e.g. 192.168.1.100)" style="margin-left: 20px; padding: 4px;">
|
||||
<button onclick="addManualDevice()">Add Device</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 3: Data Sync -->
|
||||
<div id="tab-sync" class="tab-content">
|
||||
<h2>Initial Data Sync</h2>
|
||||
<p>Before migrating, fetch your presets, recents, and configured sources from the device to ensure they are available locally.</p>
|
||||
<div class="device-selection">
|
||||
<label for="sync-device-list">Device:</label>
|
||||
<select id="sync-device-list">
|
||||
<option value="">-- Select a device --</option>
|
||||
</select>
|
||||
<button id="sync-now-btn">Start Sync</button>
|
||||
</div>
|
||||
<div id="sync-status" class="status"></div>
|
||||
<div id="sync-results" style="margin-top: 20px; display: none;">
|
||||
<h3>Sync Results</h3>
|
||||
<div id="sync-log" style="font-family: monospace; background: #f4f4f4; padding: 10px; border-radius: 4px; max-height: 300px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 4: Migration -->
|
||||
<div id="tab-migration" class="tab-content">
|
||||
<h2>Device Migration</h2>
|
||||
<div class="device-selection">
|
||||
<label for="migration-device-list">Device:</label>
|
||||
<select id="migration-device-list" onchange="showSummary(this.value)">
|
||||
<option value="">-- Select a device --</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
|
||||
<div id="command-output-box" class="summary-box" style="display: none; background-color: #f0f0f0;">
|
||||
<h3>Command Output</h3>
|
||||
<div id="command-output" style="font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 300px; overflow-y: auto; padding: 10px; border: 1px solid #ccc; background: #fff;"></div>
|
||||
</div>
|
||||
|
||||
<div id="migration-summary" class="summary-box" style="display: none;">
|
||||
<h3>Migration Summary for <span id="summary-device-display"></span></h3>
|
||||
<p>Migration Status: <span id="migration-status"></span></p>
|
||||
<input type="hidden" id="summary-device-id">
|
||||
<p>SSH Connection: <span id="ssh-status"></span></p>
|
||||
<p id="original-config-status" style="display: none;">Backup: ✅ Found .original config at <code>/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original</code> <button onclick="toggleOriginalConfig()">Show Original Config</button></p>
|
||||
<p id="no-original-config-status" style="display: none;">Backup: ❌ Not found <button id="backup-config-btn">Backup Config Now</button></p>
|
||||
<p>Remote Services Enabled: <span id="remote-services-status"></span> <span id="remote-services-found" style="font-size: 0.8em; color: #666;"></span></p>
|
||||
<p>AfterTouch Local Root CA Trusted: <span id="ca-trust-status"></span> <button id="trust-ca-btn" style="display: none; background-color: #607D8B; color: white; border: none; padding: 2px 8px; font-size: 0.8em; margin-left: 10px;">Trust CA Now</button></p>
|
||||
|
||||
<div id="connection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #eefbff;">
|
||||
<strong>HTTPS Connection Test:</strong><br>
|
||||
<span style="font-size: 0.85em; color: #555;">Verify the device can reach the server over HTTPS.</span>
|
||||
<div style="margin-top: 10px;">
|
||||
URL: <code id="test-url"></code>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<button id="test-connection-explicit-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Explicit CA.crt</button>
|
||||
<button id="test-connection-trusted-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Shared Trust Store</button>
|
||||
</div>
|
||||
<div id="test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
|
||||
<div id="hosts-redirection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #fff4e6; display: none;">
|
||||
<strong>Preliminary /etc/hosts Test:</strong><br>
|
||||
<span style="font-size: 0.85em; color: #555;">Verify the device's /etc/hosts mechanism before full migration.</span>
|
||||
<div style="margin-top: 10px;">
|
||||
Domain: <code>custom-test-api.bose.fake</code>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<button id="test-hosts-btn" style="background-color: #FF9800; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test Hosts Redirection</button>
|
||||
</div>
|
||||
<div id="hosts-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
|
||||
<div id="dns-redirection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #e6ffed; display: none;">
|
||||
<strong>Preliminary DNS Test:</strong><br>
|
||||
<span style="font-size: 0.85em; color: #555;">Verify the device can resolve domains via the AfterTouch DNS server.</span>
|
||||
<div style="margin-top: 10px;">
|
||||
Domain: <code>aftertouch.test</code>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<button id="test-dns-btn" style="background-color: #28a745; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test DNS Redirection</button>
|
||||
</div>
|
||||
<div id="dns-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #f9f9f9;">
|
||||
<label for="migration-method"><strong>Migration Method:</strong></label>
|
||||
<select id="migration-method" onchange="toggleMigrationMethod()">
|
||||
<option value="xml">XML Configuration (Recommended - redirects specific services)</option>
|
||||
<option value="hosts">/etc/hosts + Root CA (Advanced - global redirection)</option>
|
||||
<option value="resolv">/etc/resolv.conf (DHCP-Aware - Redirect via DNS Hook)</option>
|
||||
</select>
|
||||
<div id="dns-port-warning" style="margin-top: 5px; color: #d32f2f; font-weight: bold; font-size: 0.9em; display: none;"></div>
|
||||
</div>
|
||||
|
||||
<div id="current-resolv-pane" style="display: none; margin-bottom: 20px;">
|
||||
<span class="config-header">Current /etc/resolv.conf</span>
|
||||
<pre id="current-resolv-content"></pre>
|
||||
</div>
|
||||
|
||||
<div id="original-config-pane" style="display: none; margin-bottom: 20px;">
|
||||
<span class="config-header">Original Config (Backup)</span>
|
||||
<pre id="original-config-content"></pre>
|
||||
</div>
|
||||
|
||||
<div id="service-options" style="margin-bottom: 20px; display: none;">
|
||||
<h4>Service Implementations</h4>
|
||||
<table>
|
||||
<tr><th>Service</th><th>Original URL</th><th>Implementation</th></tr>
|
||||
<tr>
|
||||
<td>Marge (Streaming)</td>
|
||||
<td id="orig-marge">loading...</td>
|
||||
<td>
|
||||
<select id="opt-marge" onchange="refreshSummary()">
|
||||
<option value="self">AfterTouch (Local Service)</option>
|
||||
<option value="upstream">Upstream (Proxy via local service)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stats</td>
|
||||
<td id="orig-stats">loading...</td>
|
||||
<td>
|
||||
<select id="opt-stats" onchange="refreshSummary()">
|
||||
<option value="self">AfterTouch (Local Service)</option>
|
||||
<option value="upstream">Upstream (Proxy via local service)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Software Update</td>
|
||||
<td id="orig-sw_update">loading...</td>
|
||||
<td>
|
||||
<select id="opt-sw_update" onchange="refreshSummary()">
|
||||
<option value="self">AfterTouch (Local Service)</option>
|
||||
<option value="upstream">Upstream (Proxy via local service)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>BMX (Registry)</td>
|
||||
<td id="orig-bmx">loading...</td>
|
||||
<td>
|
||||
<select id="opt-bmx" onchange="refreshSummary()">
|
||||
<option value="self">AfterTouch (Local Service)</option>
|
||||
<option value="upstream">Upstream (Proxy via local service)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="diff-container">
|
||||
<div id="xml-diff-pane" class="diff-pane">
|
||||
<span class="config-header">Current Config (on Speaker)</span>
|
||||
<pre id="current-config"></pre>
|
||||
</div>
|
||||
<div id="planned-xml-pane" class="diff-pane">
|
||||
<span class="config-header">Planned Config (AfterTouch)</span>
|
||||
<pre id="planned-config"></pre>
|
||||
</div>
|
||||
<div id="planned-hosts-pane" class="diff-pane" style="display: none;">
|
||||
<span class="config-header">Planned /etc/hosts Entries</span>
|
||||
<pre id="planned-hosts"></pre>
|
||||
<div style="margin-top: 10px; font-size: 0.9em; color: #666;">
|
||||
<strong>Note:</strong> This method also injects the AfterTouch Local Root CA into <code>/etc/pki/tls/certs/ca-bundle.crt</code> to enable secure HTTPS communication.
|
||||
</div>
|
||||
</div>
|
||||
<div id="planned-resolv-pane" class="diff-pane" style="display: none;">
|
||||
<span class="config-header">Planned /etc/resolv.conf Hook</span>
|
||||
<pre id="planned-resolv"></pre>
|
||||
<div id="resolv-note" style="margin-top: 10px; font-size: 0.9em; color: #666;">
|
||||
<strong>Note:</strong> This method injects a persistent DNS priority hook into the DHCP logic (<code>/etc/udhcpc.d/50default</code>). It preserves your router's search domain and secondary DNS servers. It also injects the Local Root CA.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 15px;">
|
||||
<button id="confirm-migrate-btn" style="background-color: #4CAF50; color: white; border: none; padding: 10px 20px;">Confirm Migration</button>
|
||||
<button id="revert-migrate-btn" style="background-color: #FF9800; color: white; border: none; padding: 10px 20px; display: none;">Revert to Defaults</button>
|
||||
<button id="reboot-speaker-btn" style="background-color: #607D8B; color: white; border: none; padding: 10px 20px;">Reboot Speaker</button>
|
||||
<button id="ensure-remote-btn" style="background-color: #2196F3; color: white; border: none; padding: 10px 20px;">Enable Persistent Remote Services</button>
|
||||
<button id="remove-remote-btn" style="background-color: #f44336; color: white; border: none; padding: 10px 20px;">Remove Persistent Remote Services</button>
|
||||
<button onclick="document.getElementById('migration-summary').style.display='none'" style="padding: 10px 20px;">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 5: Interactions & Events -->
|
||||
<div id="tab-interactions" class="tab-content">
|
||||
<h2>Recorded Interactions & Device Events</h2>
|
||||
<p>Analysis of traffic handled by this service (self), proxied to Bose (upstream), and internal device events (telemetry).</p>
|
||||
|
||||
<div id="interaction-stats-container" class="summary-box">
|
||||
<div style="display: flex; gap: 20px; align-items: center; margin-bottom: 15px;">
|
||||
<p style="margin: 0;">Total Requests: <strong id="total-requests">0</strong></p>
|
||||
<button onclick="fetchInteractionStats()">Refresh Stats</button>
|
||||
<div style="margin-left: 10px;">
|
||||
<button onclick="showDeviceEvents()">View App/Device Events</button>
|
||||
</div>
|
||||
<div style="margin-left: auto; text-align: right;">
|
||||
<button onclick="cleanupSessions()" class="btn-danger">Cleanup old sessions</button>
|
||||
<div style="font-size: 0.75em; color: #666; margin-top: 3px;">Keeps only the 10 most recent sessions</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 20px;">
|
||||
<div style="flex: 1; border-right: 1px solid #eee; padding-right: 20px;">
|
||||
<h3>By Service</h3>
|
||||
<ul id="stats-by-service" class="stats-list"></ul>
|
||||
</div>
|
||||
<div style="flex: 2;">
|
||||
<h3>Sessions</h3>
|
||||
<div id="stats-by-session-container" style="max-height: 200px; overflow-y: auto; border: 1px solid #eee; padding: 5px; border-radius: 4px;">
|
||||
<ul id="stats-by-session" class="stats-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="browse-recordings" class="summary-box" style="margin-top: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0;">Browse Recordings</h3>
|
||||
</div>
|
||||
<div style="margin-bottom: 15px; display: flex; gap: 15px; align-items: center; background: #f9f9f9; padding: 10px; border-radius: 4px;">
|
||||
<div>
|
||||
<label for="filter-session">Session:</label>
|
||||
<select id="filter-session" onchange="fetchInteractions()">
|
||||
<option value="">All Sessions</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filter-category">Category:</label>
|
||||
<select id="filter-category" onchange="fetchInteractions()">
|
||||
<option value="">All Categories</option>
|
||||
<option value="self">Self (Emulated)</option>
|
||||
<option value="upstream">Upstream (Bose)</option>
|
||||
<option value="mirror">Mirror (Bose)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filter-since">Since (YYYY-MM-DD HH:mm:ss):</label>
|
||||
<input type="text" id="filter-since" placeholder="e.g. 2026-02-15 15:00:00" size="25" onchange="fetchInteractions()">
|
||||
</div>
|
||||
<button onclick="fetchInteractions()">Apply Filters</button>
|
||||
</div>
|
||||
|
||||
<div id="interactions-list-container" style="max-height: 400px; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="text-align: left; border-bottom: 2px solid #eee;">
|
||||
<th style="padding: 8px;">#</th>
|
||||
<th style="padding: 8px;">Time</th>
|
||||
<th style="padding: 8px;">Method</th>
|
||||
<th style="padding: 8px;">Path</th>
|
||||
<th style="padding: 8px;">Status</th>
|
||||
<th style="padding: 8px;">Category</th>
|
||||
<th style="padding: 8px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="interactions-list">
|
||||
<tr><td colspan="7" style="padding: 20px; text-align: center; color: #666;">No interactions found.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dns-discoveries" class="summary-box" style="margin-top: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0;">DNS Discoveries</h3>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<button onclick="downloadDNSDiscoveries()" class="btn-info">Download JSON</button>
|
||||
<button onclick="clearDNSDiscoveries()" class="btn-danger">Clear DNS Logs</button>
|
||||
</div>
|
||||
</div>
|
||||
<p style="font-size: 0.85em; color: #666;">Hosts discovered via the AfterTouch DNS server. "Self" means the domain was intercepted and redirected to this service.</p>
|
||||
<div id="dns-discoveries-list-container" style="max-height: 400px; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="text-align: left; border-bottom: 2px solid #eee;">
|
||||
<th style="padding: 8px;">Hostname</th>
|
||||
<th style="padding: 8px;">Last Seen</th>
|
||||
<th style="padding: 8px; text-align: center;">Queries</th>
|
||||
<th style="padding: 8px; text-align: center;">Bose?</th>
|
||||
<th style="padding: 8px;">Category</th>
|
||||
<th style="padding: 8px;">Last Client IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dns-discoveries-list">
|
||||
<tr><td colspan="6" style="padding: 20px; text-align: center; color: #666;">No DNS discoveries found.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="interaction-viewer" class="summary-box" style="margin-top: 20px; display: none; background: #2b2b2b; color: #a9b7c6;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<h3 style="margin: 0; color: #fff;">Recording Viewer: <span id="viewer-filename" style="font-weight: normal; font-size: 0.8em;"></span></h3>
|
||||
<button onclick="document.getElementById('interaction-viewer').style.display='none'" style="background: #444; color: #fff; border: 1px solid #666;">Close</button>
|
||||
</div>
|
||||
<pre id="interaction-content" style="white-space: pre-wrap; font-family: 'Courier New', Courier, monospace; font-size: 0.9em; margin: 0; padding: 10px; overflow-x: auto; max-height: 600px;"></pre>
|
||||
</div>
|
||||
|
||||
<!-- Device Events Overlay -->
|
||||
<div id="device-events-overlay" class="summary-box" style="margin-top: 20px; display: none; background: #fdfdfd; border: 1px solid #ddd;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<h3 style="margin: 0;">App & Device Events</h3>
|
||||
<div>
|
||||
<select id="event-device-selector" onchange="fetchDeviceEvents(this.value)">
|
||||
<option value="">-- Select Device --</option>
|
||||
</select>
|
||||
<button onclick="document.getElementById('device-events-overlay').style.display='none'" style="margin-left: 10px;">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="events-list-container" style="max-height: 400px; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="text-align: left; border-bottom: 2px solid #eee;">
|
||||
<th style="padding: 8px;">Time</th>
|
||||
<th style="padding: 8px;">Type</th>
|
||||
<th style="padding: 8px;">Data</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="events-list">
|
||||
<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Select a device to view events.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 6: Parity & Mirroring -->
|
||||
<div id="tab-parity" class="tab-content">
|
||||
<h2>Parity Analysis</h2>
|
||||
<p>Detection of discrepancies between AfterTouch local responses and official Bose Cloud responses for mirrored endpoints.</p>
|
||||
|
||||
<div class="summary-box">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0;">Parity Mismatches</h3>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<button onclick="fetchParityMismatches()">Refresh Mismatches</button>
|
||||
<button onclick="clearParityMismatches()" class="btn-danger">Clear All Records</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="parity-list-container" style="max-height: 500px; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="text-align: left; border-bottom: 2px solid #eee;">
|
||||
<th style="padding: 8px;">Time</th>
|
||||
<th style="padding: 8px;">Method</th>
|
||||
<th style="padding: 8px;">Path</th>
|
||||
<th style="padding: 8px;">Reasons</th>
|
||||
<th style="padding: 8px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="parity-mismatches-list">
|
||||
<tr><td colspan="5" style="padding: 20px; text-align: center; color: #666;">Loading mismatches...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="parity-diff-view" class="summary-box" style="display: none; margin-top: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0;">Mismatch Detail: <span id="diff-path-display"></span></h3>
|
||||
<button onclick="document.getElementById('parity-diff-view').style.display='none'">Close Detail</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 15px; padding: 10px; background: #fff4f4; border: 1px solid #f5c6cb; border-radius: 4px; color: #721c24;">
|
||||
<strong>Detection Reasons:</strong>
|
||||
<ul id="diff-reasons-list" style="margin: 5px 0 0 0; padding-left: 20px;"></ul>
|
||||
</div>
|
||||
|
||||
<div class="diff-container" style="margin-top: 15px;">
|
||||
<div class="diff-pane">
|
||||
<span class="config-header" style="background: #eefbff; color: #0056b3;">Local Response (AfterTouch)</span>
|
||||
<div id="diff-local-meta" style="font-size: 0.8em; margin-bottom: 5px; color: #666;"></div>
|
||||
<pre id="diff-local-body" style="background: #f8f9fa; border: 1px solid #eee; padding: 10px; font-size: 0.85em; overflow-x: auto;"></pre>
|
||||
</div>
|
||||
<div class="diff-pane">
|
||||
<span class="config-header" style="background: #fff4e6; color: #856404;">Upstream Response (Bose)</span>
|
||||
<div id="diff-upstream-meta" style="font-size: 0.8em; margin-bottom: 5px; color: #666;"></div>
|
||||
<pre id="diff-upstream-body" style="background: #f8f9fa; border: 1px solid #eee; padding: 10px; font-size: 0.85em; overflow-x: auto;"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/web/migration/" class="choice-card">
|
||||
<div class="icon">⚙️</div>
|
||||
<div class="title">Migration</div>
|
||||
<div class="desc">Setup, data sync, and cloud migration toolkit.</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/web/js/script.js"></script>
|
||||
<footer style="margin-top: 50px; padding: 20px; border-top: 1px solid #eee; font-size: 0.8em; color: #888; text-align: center;">
|
||||
<footer>
|
||||
<span id="version-info">AfterTouch</span>
|
||||
</footer>
|
||||
|
||||
<script src="/web/shared/common.js"></script>
|
||||
<script>
|
||||
fetchVersion();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>AfterTouch (SoundTouch Toolkit)</title>
|
||||
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="../shared/common.css">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>AfterTouch</h1>
|
||||
<p style="margin-top: -10px; font-style: italic; color: #666;">Bose SoundTouch Toolkit</p>
|
||||
<p style="margin-bottom: 20px;"><a href="/">← Back to selection</a></p>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab-buttons">
|
||||
<button class="tab-btn active" onclick="openTab(event, 'tab-overview')">Overview</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-settings')">1. Settings</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-devices')">2. Devices</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-sync')">3. Data Sync</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-migration')">4. Migration</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-interactions')">5. Interactions & Events</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 0: Overview -->
|
||||
<div id="tab-overview" class="tab-content active">
|
||||
<h2>Welcome to AfterTouch</h2>
|
||||
<p>This toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026. It emulates the necessary cloud services locally on your network.</p>
|
||||
|
||||
<h3>Migration Process at a Glance</h3>
|
||||
<div class="info-box prerequisite-box">
|
||||
<strong>🔌 Prerequisite: Enable SSH</strong><br>
|
||||
Migration requires SSH access. To enable it:
|
||||
<ol style="margin-top: 5px; margin-bottom: 5px;">
|
||||
<li>Create an empty file named <code>remote_services</code> on a USB stick.</li>
|
||||
<li>Insert it into the speaker's <strong>SERVICE</strong> port and reboot the speaker.</li>
|
||||
</ol>
|
||||
<strong>Verify connection:</strong>
|
||||
<ul style="margin-top: 5px; margin-bottom: 0; padding-left: 20px;">
|
||||
<li>Use the <strong>Migration</strong> tab to select your device and verify that <em>SSH Connection</em> shows ✅ Success.</li>
|
||||
<li>Or manually: <code>ssh -oHostKeyAlgorithms=+ssh-rsa root@<SPEAKER-IP></code> (no password).</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ol class="guide-steps">
|
||||
<li>
|
||||
<strong>Settings:</strong> Review the <strong>Settings</strong> tab. Ensure the "Target Domain" and "Proxy Domain" use an IP address or domain name that is <strong>accessible from your speakers</strong> (usually the IP of this server on your local network).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Discovery:</strong> Go to the <strong>Devices</strong> tab to find your speakers on the network.
|
||||
Ensure your speakers are powered on and connected to the same network.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Data Sync:</strong> In the <strong>Data Sync</strong> tab, fetch your current presets, recents, and sources.
|
||||
This step is critical to ensure your local service has all your personalized data before you disconnect from the Bose cloud.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Migration:</strong> In the <strong>Migration</strong> tab, redirect your speaker to this local service.
|
||||
We recommend the <strong>XML Configuration</strong> method as it is surgical and easily reversible.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Verification:</strong> After migration and reboot, your speaker will communicate with this toolkit instead of Bose servers.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<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.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.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>
|
||||
|
||||
<!-- Tab 1: Settings -->
|
||||
<div id="tab-settings" class="tab-content">
|
||||
<h2>System Settings</h2>
|
||||
<p style="font-size: 0.9em; color: #555; margin-bottom: 20px;">
|
||||
<strong>Note:</strong> These URLs must be <strong>accessible from your SoundTouch devices</strong>.
|
||||
Use the IP address of this server on your local network (e.g., <code>http://192.168.1.100:8000</code>)
|
||||
rather than <code>localhost</code>.
|
||||
</p>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label for="target-domain">Target Domain:</label>
|
||||
<input type="text" id="target-domain" placeholder="http://192.168.x.x:8000" style="width: 300px;">
|
||||
<span style="font-size: 0.8em; color: #666;">(Standard services URL)</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label for="soundcork-url">Soundcork URL:</label>
|
||||
<input type="text" id="soundcork-url" placeholder="http://192.168.x.x:8001" style="width: 300px;">
|
||||
<span style="font-size: 0.8em; color: #666;">(Soundcork services URL)</span>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label for="discovery-interval">Discovery Interval:</label>
|
||||
<input type="text" id="discovery-interval" placeholder="5m" style="width: 100px;">
|
||||
<label style="margin-left: 15px;"><input type="checkbox" id="discovery-enabled"> Enable Automated Discovery</label>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<button onclick="updateSettings()">Save Settings</button>
|
||||
<span id="settings-status" style="margin-left: 10px; font-size: 0.9em;"></span>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<strong>DNS Discovery:</strong>
|
||||
<div style="margin-top: 5px;">
|
||||
<label style="display: block; margin-bottom: 5px;">
|
||||
<input type="checkbox" id="dns-enabled"> Enable DNS Discovery Server
|
||||
</label>
|
||||
<div style="margin-left: 20px; margin-bottom: 5px;">
|
||||
<label for="dns-upstream">Upstream DNS:</label>
|
||||
<input type="text" id="dns-upstream" placeholder="8.8.8.8" style="width: 150px;">
|
||||
<span style="font-size: 0.8em; color: #666; margin-left: 5px;">(For non-intercepted queries)</span>
|
||||
</div>
|
||||
<div style="margin-left: 20px;">
|
||||
<label for="dns-bind">DNS Bind Address:</label>
|
||||
<input type="text" id="dns-bind" placeholder=":53" style="width: 100px;">
|
||||
<span style="font-size: 0.8em; color: #666; margin-left: 5px;">(e.g., :53 or 0.0.0.0:53. <strong>Port 53</strong> is required for actual migration)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<strong>Proxy Logging:</strong>
|
||||
<div style="margin-top: 5px;">
|
||||
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"> Redact Sensitive Headers</label>
|
||||
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"> Log Bodies</label>
|
||||
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="enable-soundcork-proxy" onchange="updateProxySettings()"> Enable Soundcork Proxy (Legacy)</label>
|
||||
<label style="display: block; margin-bottom: 5px;">
|
||||
<input type="checkbox" id="proxy-record" onchange="updateProxySettings()"> Record Interactions
|
||||
<span style="font-size: 0.85em; color: #666; margin-left: 5px;">(View in <strong>5. Interactions</strong> tab)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Devices -->
|
||||
<div id="tab-devices" class="tab-content">
|
||||
<h2>Known Devices <span id="discovery-indicator" style="font-size: 0.5em; vertical-align: middle; display: none;">🔍 Scanning...</span></h2>
|
||||
<div id="device-list">Loading devices...</div>
|
||||
<div style="margin-top: 20px;">
|
||||
<button onclick="triggerDiscovery()">Scan Again</button>
|
||||
<input type="text" id="add-manual-ip" placeholder="Manual IP (e.g. 192.168.1.100)" style="margin-left: 20px; padding: 4px;">
|
||||
<button onclick="addManualDevice()">Add Device</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 3: Data Sync -->
|
||||
<div id="tab-sync" class="tab-content">
|
||||
<h2>Initial Data Sync</h2>
|
||||
<p>Before migrating, fetch your presets, recents, and configured sources from the device to ensure they are available locally.</p>
|
||||
<div class="device-selection">
|
||||
<label for="sync-device-list">Device:</label>
|
||||
<select id="sync-device-list">
|
||||
<option value="">-- Select a device --</option>
|
||||
</select>
|
||||
<button id="sync-now-btn">Start Sync</button>
|
||||
</div>
|
||||
<div id="sync-status" class="status"></div>
|
||||
<div id="sync-results" style="margin-top: 20px; display: none;">
|
||||
<h3>Sync Results</h3>
|
||||
<div id="sync-log" style="font-family: monospace; background: #f4f4f4; padding: 10px; border-radius: 4px; max-height: 300px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 4: Migration -->
|
||||
<div id="tab-migration" class="tab-content">
|
||||
<h2>Device Migration</h2>
|
||||
<div class="device-selection">
|
||||
<label for="migration-device-list">Device:</label>
|
||||
<select id="migration-device-list" onchange="showSummary(this.value)">
|
||||
<option value="">-- Select a device --</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
|
||||
<div id="command-output-box" class="summary-box" style="display: none; background-color: #f0f0f0;">
|
||||
<h3>Command Output</h3>
|
||||
<div id="command-output" style="font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 300px; overflow-y: auto; padding: 10px; border: 1px solid #ccc; background: #fff;"></div>
|
||||
</div>
|
||||
|
||||
<div id="migration-summary" class="summary-box" style="display: none;">
|
||||
<h3>Migration Summary for <span id="summary-ip"></span></h3>
|
||||
<p>Migration Status: <span id="migration-status"></span></p>
|
||||
<p>SSH Connection: <span id="ssh-status"></span></p>
|
||||
<p id="original-config-status" style="display: none;">Backup: ✅ Found .original config at <code>/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original</code> <button onclick="toggleOriginalConfig()">Show Original Config</button></p>
|
||||
<p id="no-original-config-status" style="display: none;">Backup: ❌ Not found <button id="backup-config-btn">Backup Config Now</button></p>
|
||||
<p>Remote Services Enabled: <span id="remote-services-status"></span> <span id="remote-services-found" style="font-size: 0.8em; color: #666;"></span></p>
|
||||
<p>AfterTouch Local Root CA Trusted: <span id="ca-trust-status"></span> <button id="trust-ca-btn" style="display: none; background-color: #607D8B; color: white; border: none; padding: 2px 8px; font-size: 0.8em; margin-left: 10px;">Trust CA Now</button></p>
|
||||
|
||||
<div id="connection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #eefbff;">
|
||||
<strong>HTTPS Connection Test:</strong><br>
|
||||
<span style="font-size: 0.85em; color: #555;">Verify the device can reach the server over HTTPS.</span>
|
||||
<div style="margin-top: 10px;">
|
||||
URL: <code id="test-url"></code>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<button id="test-connection-explicit-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Explicit CA.crt</button>
|
||||
<button id="test-connection-trusted-btn" style="background-color: #607D8B; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test with Shared Trust Store</button>
|
||||
</div>
|
||||
<div id="test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
|
||||
<div id="hosts-redirection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #fff4e6; display: none;">
|
||||
<strong>Preliminary /etc/hosts Test:</strong><br>
|
||||
<span style="font-size: 0.85em; color: #555;">Verify the device's /etc/hosts mechanism before full migration.</span>
|
||||
<div style="margin-top: 10px;">
|
||||
Domain: <code>custom-test-api.bose.fake</code>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<button id="test-hosts-btn" style="background-color: #FF9800; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test Hosts Redirection</button>
|
||||
</div>
|
||||
<div id="hosts-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
|
||||
<div id="dns-redirection-test" style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #e6ffed; display: none;">
|
||||
<strong>Preliminary DNS Test:</strong><br>
|
||||
<span style="font-size: 0.85em; color: #555;">Verify the device can resolve domains via the AfterTouch DNS server.</span>
|
||||
<div style="margin-top: 10px;">
|
||||
Domain: <code>aftertouch.test</code>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<button id="test-dns-btn" style="background-color: #28a745; color: white; border: none; padding: 5px 10px; font-size: 0.9em;">Test DNS Redirection</button>
|
||||
</div>
|
||||
<div id="dns-test-result" style="margin-top: 10px; display: none; padding: 10px; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 0.85em; max-height: 200px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin: 15px 0; padding: 10px; border: 1px solid #ddd; background-color: #f9f9f9;">
|
||||
<label for="migration-method"><strong>Migration Method:</strong></label>
|
||||
<select id="migration-method" onchange="toggleMigrationMethod()">
|
||||
<option value="xml">XML Configuration (Recommended - redirects specific services)</option>
|
||||
<option value="hosts">/etc/hosts + Root CA (Advanced - global redirection)</option>
|
||||
<option value="resolv">/etc/resolv.conf (DHCP-Aware - Most flexible)</option>
|
||||
</select>
|
||||
<div id="dns-port-warning" style="margin-top: 5px; color: #d32f2f; font-weight: bold; font-size: 0.9em; display: none;"></div>
|
||||
</div>
|
||||
|
||||
<div id="current-resolv-pane" style="display: none; margin-bottom: 20px;">
|
||||
<span class="config-header">Current /etc/resolv.conf</span>
|
||||
<pre id="current-resolv-content"></pre>
|
||||
</div>
|
||||
|
||||
<div id="original-config-pane" style="display: none; margin-bottom: 20px;">
|
||||
<span class="config-header">Original Config (Backup)</span>
|
||||
<pre id="original-config-content"></pre>
|
||||
</div>
|
||||
|
||||
<div id="service-options" style="margin-bottom: 20px; display: none;">
|
||||
<h4>Service Implementations</h4>
|
||||
<table>
|
||||
<tr><th>Service</th><th>Original URL</th><th>Implementation</th></tr>
|
||||
<tr>
|
||||
<td>Marge (Streaming)</td>
|
||||
<td id="orig-marge">loading...</td>
|
||||
<td>
|
||||
<select id="opt-marge" onchange="refreshSummary()">
|
||||
<option value="self">AfterTouch (Local Service)</option>
|
||||
<option value="upstream">Upstream (Proxy via local service)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stats</td>
|
||||
<td id="orig-stats">loading...</td>
|
||||
<td>
|
||||
<select id="opt-stats" onchange="refreshSummary()">
|
||||
<option value="self">AfterTouch (Local Service)</option>
|
||||
<option value="upstream">Upstream (Proxy via local service)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Software Update</td>
|
||||
<td id="orig-sw_update">loading...</td>
|
||||
<td>
|
||||
<select id="opt-sw_update" onchange="refreshSummary()">
|
||||
<option value="self">AfterTouch (Local Service)</option>
|
||||
<option value="upstream">Upstream (Proxy via local service)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>BMX (Registry)</td>
|
||||
<td id="orig-bmx">loading...</td>
|
||||
<td>
|
||||
<select id="opt-bmx" onchange="refreshSummary()">
|
||||
<option value="self">AfterTouch (Local Service)</option>
|
||||
<option value="upstream">Upstream (Proxy via local service)</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="diff-container">
|
||||
<div id="xml-diff-pane" class="diff-pane">
|
||||
<span class="config-header">Current Config (on Speaker)</span>
|
||||
<pre id="current-config"></pre>
|
||||
</div>
|
||||
<div id="planned-xml-pane" class="diff-pane">
|
||||
<span class="config-header">Planned Config (AfterTouch)</span>
|
||||
<pre id="planned-config"></pre>
|
||||
</div>
|
||||
<div id="planned-hosts-pane" class="diff-pane" style="display: none;">
|
||||
<span class="config-header">Planned /etc/hosts Entries</span>
|
||||
<pre id="planned-hosts"></pre>
|
||||
<div style="margin-top: 10px; font-size: 0.9em; color: #666;">
|
||||
<strong>Note:</strong> This method also injects the AfterTouch Local Root CA into <code>/etc/pki/tls/certs/ca-bundle.crt</code> to enable secure HTTPS communication.
|
||||
</div>
|
||||
</div>
|
||||
<div id="planned-resolv-pane" class="diff-pane" style="display: none;">
|
||||
<span class="config-header">Planned /etc/resolv.conf Hook</span>
|
||||
<pre id="planned-resolv"></pre>
|
||||
<div id="resolv-note" style="margin-top: 10px; font-size: 0.9em; color: #666;">
|
||||
<strong>Note:</strong> This method injects a persistent DNS priority hook into the DHCP logic (<code>/etc/udhcpc.d/50default</code>). It preserves your router's search domain and secondary DNS servers. It also injects the Local Root CA.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 15px;">
|
||||
<button id="confirm-migrate-btn" style="background-color: #4CAF50; color: white; border: none; padding: 10px 20px;">Confirm Migration</button>
|
||||
<button id="revert-migrate-btn" style="background-color: #FF9800; color: white; border: none; padding: 10px 20px; display: none;">Revert to Defaults</button>
|
||||
<button id="reboot-speaker-btn" style="background-color: #607D8B; color: white; border: none; padding: 10px 20px;">Reboot Speaker</button>
|
||||
<button id="ensure-remote-btn" style="background-color: #2196F3; color: white; border: none; padding: 10px 20px;">Enable Persistent Remote Services</button>
|
||||
<button id="remove-remote-btn" style="background-color: #f44336; color: white; border: none; padding: 10px 20px;">Remove Persistent Remote Services</button>
|
||||
<button onclick="document.getElementById('migration-summary').style.display='none'" style="padding: 10px 20px;">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 5: Interactions & Events -->
|
||||
<div id="tab-interactions" class="tab-content">
|
||||
<h2>Recorded Interactions & Device Events</h2>
|
||||
<p>Analysis of traffic handled by this service (self), proxied to Bose (upstream), and internal device events (telemetry).</p>
|
||||
|
||||
<div id="interaction-stats-container" class="summary-box">
|
||||
<div style="display: flex; gap: 20px; align-items: center; margin-bottom: 15px;">
|
||||
<p style="margin: 0;">Total Requests: <strong id="total-requests">0</strong></p>
|
||||
<button onclick="fetchInteractionStats()">Refresh Stats</button>
|
||||
<div style="margin-left: 10px;">
|
||||
<button onclick="showDeviceEvents()">View App/Device Events</button>
|
||||
</div>
|
||||
<div style="margin-left: auto; text-align: right;">
|
||||
<button onclick="cleanupSessions()" class="btn-danger">Cleanup old sessions</button>
|
||||
<div style="font-size: 0.75em; color: #666; margin-top: 3px;">Keeps only the 10 most recent sessions</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 20px;">
|
||||
<div style="flex: 1; border-right: 1px solid #eee; padding-right: 20px;">
|
||||
<h3>By Service</h3>
|
||||
<ul id="stats-by-service" class="stats-list"></ul>
|
||||
</div>
|
||||
<div style="flex: 2;">
|
||||
<h3>Sessions</h3>
|
||||
<div id="stats-by-session-container" style="max-height: 200px; overflow-y: auto; border: 1px solid #eee; padding: 5px; border-radius: 4px;">
|
||||
<ul id="stats-by-session" class="stats-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="browse-recordings" class="summary-box" style="margin-top: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0;">Browse Recordings</h3>
|
||||
</div>
|
||||
<div style="margin-bottom: 15px; display: flex; gap: 15px; align-items: center; background: #f9f9f9; padding: 10px; border-radius: 4px;">
|
||||
<div>
|
||||
<label for="filter-session">Session:</label>
|
||||
<select id="filter-session" onchange="fetchInteractions()">
|
||||
<option value="">All Sessions</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filter-category">Category:</label>
|
||||
<select id="filter-category" onchange="fetchInteractions()">
|
||||
<option value="">All Categories</option>
|
||||
<option value="self">Self (Emulated)</option>
|
||||
<option value="upstream">Upstream (Bose)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filter-since">Since (YYYY-MM-DD HH:mm:ss):</label>
|
||||
<input type="text" id="filter-since" placeholder="e.g. 2026-02-15 15:00:00" size="25" onchange="fetchInteractions()">
|
||||
</div>
|
||||
<button onclick="fetchInteractions()">Apply Filters</button>
|
||||
</div>
|
||||
|
||||
<div id="interactions-list-container" style="max-height: 400px; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="text-align: left; border-bottom: 2px solid #eee;">
|
||||
<th style="padding: 8px;">#</th>
|
||||
<th style="padding: 8px;">Time</th>
|
||||
<th style="padding: 8px;">Method</th>
|
||||
<th style="padding: 8px;">Path</th>
|
||||
<th style="padding: 8px;">Status</th>
|
||||
<th style="padding: 8px;">Category</th>
|
||||
<th style="padding: 8px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="interactions-list">
|
||||
<tr><td colspan="7" style="padding: 20px; text-align: center; color: #666;">No interactions found.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dns-discoveries" class="summary-box" style="margin-top: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0;">DNS Discoveries</h3>
|
||||
<button onclick="clearDNSDiscoveries()" class="btn-danger">Clear DNS Logs</button>
|
||||
</div>
|
||||
<p style="font-size: 0.85em; color: #666;">Hosts discovered via the AfterTouch DNS server. "Self" means the domain was intercepted and redirected to this service.</p>
|
||||
<div id="dns-discoveries-list-container" style="max-height: 400px; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="text-align: left; border-bottom: 2px solid #eee;">
|
||||
<th style="padding: 8px;">Hostname</th>
|
||||
<th style="padding: 8px;">Last Seen</th>
|
||||
<th style="padding: 8px; text-align: center;">Queries</th>
|
||||
<th style="padding: 8px; text-align: center;">Bose?</th>
|
||||
<th style="padding: 8px;">Category</th>
|
||||
<th style="padding: 8px;">Last Client IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dns-discoveries-list">
|
||||
<tr><td colspan="6" style="padding: 20px; text-align: center; color: #666;">No DNS discoveries found.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="interaction-viewer" class="summary-box" style="margin-top: 20px; display: none; background: #2b2b2b; color: #a9b7c6;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<h3 style="margin: 0; color: #fff;">Recording Viewer: <span id="viewer-filename" style="font-weight: normal; font-size: 0.8em;"></span></h3>
|
||||
<button onclick="document.getElementById('interaction-viewer').style.display='none'" style="background: #444; color: #fff; border: 1px solid #666;">Close</button>
|
||||
</div>
|
||||
<pre id="interaction-content" style="white-space: pre-wrap; font-family: 'Courier New', Courier, monospace; font-size: 0.9em; margin: 0; padding: 10px; overflow-x: auto; max-height: 600px;"></pre>
|
||||
</div>
|
||||
|
||||
<!-- Device Events Overlay -->
|
||||
<div id="device-events-overlay" class="summary-box" style="margin-top: 20px; display: none; background: #fdfdfd; border: 1px solid #ddd;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<h3 style="margin: 0;">App & Device Events</h3>
|
||||
<div>
|
||||
<select id="event-device-selector" onchange="fetchDeviceEvents(this.value)">
|
||||
<option value="">-- Select Device --</option>
|
||||
</select>
|
||||
<button onclick="document.getElementById('device-events-overlay').style.display='none'" style="margin-left: 10px;">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="events-list-container" style="max-height: 400px; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="text-align: left; border-bottom: 2px solid #eee;">
|
||||
<th style="padding: 8px;">Time</th>
|
||||
<th style="padding: 8px;">Type</th>
|
||||
<th style="padding: 8px;">Data</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="events-list">
|
||||
<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Select a device to view events.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="../shared/common.js"></script>
|
||||
<script src="script.js"></script>
|
||||
<footer style="margin-top: 50px;">
|
||||
<span id="version-info">AfterTouch</span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
+159
-434
@@ -1,124 +1,3 @@
|
||||
async function fetchSpotifyStatus() {
|
||||
try {
|
||||
const settingsResponse = await fetch('/setup/settings');
|
||||
const settings = await settingsResponse.json();
|
||||
const header = document.getElementById('spotify-status-header');
|
||||
|
||||
if (!settings.spotify_configured) {
|
||||
if (header) header.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
if (header) header.style.display = 'flex';
|
||||
|
||||
const response = await fetch('/mgmt/spotify/accounts');
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
const nameEl = document.getElementById('spotify-account-name');
|
||||
const linkBtn = document.getElementById('link-spotify-btn');
|
||||
|
||||
if (data.accounts && data.accounts.length > 0) {
|
||||
header.style.background = '#e6ffed';
|
||||
header.style.border = '1px solid #28a745';
|
||||
nameEl.innerText = data.accounts[0].display_name || data.accounts[0].user_id || 'Linked';
|
||||
if (linkBtn) linkBtn.style.display = 'none';
|
||||
|
||||
// Show Prime Spotify buttons on all devices
|
||||
document.querySelectorAll('.btn-spotify').forEach(btn => {
|
||||
btn.style.display = 'inline-block';
|
||||
});
|
||||
} else {
|
||||
header.style.background = '#f0f0f0';
|
||||
header.style.border = '1px solid #ccc';
|
||||
nameEl.innerText = 'Not Linked';
|
||||
if (linkBtn) linkBtn.style.display = 'inline-block';
|
||||
|
||||
document.querySelectorAll('.btn-spotify').forEach(btn => {
|
||||
btn.style.display = 'none';
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Spotify status', error);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleInfo(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.style.display = el.style.display === 'block' ? 'none' : 'block';
|
||||
}
|
||||
}
|
||||
|
||||
async function linkSpotify() {
|
||||
try {
|
||||
const response = await fetch('/mgmt/spotify/init', { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
alert('Failed to initialize Spotify link: ' + err);
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data.redirectUrl) {
|
||||
// Open in a new tab
|
||||
const win = window.open(data.redirectUrl, '_blank');
|
||||
if (win) {
|
||||
win.focus();
|
||||
// Start polling for status change
|
||||
const pollInterval = setInterval(async () => {
|
||||
const statusResponse = await fetch('/mgmt/spotify/accounts');
|
||||
if (statusResponse.ok) {
|
||||
const statusData = await statusResponse.json();
|
||||
if (statusData.accounts && statusData.accounts.length > 0) {
|
||||
clearInterval(pollInterval);
|
||||
fetchSpotifyStatus();
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
// Stop polling after 2 minutes
|
||||
setTimeout(() => clearInterval(pollInterval), 120000);
|
||||
} else {
|
||||
alert('Please allow popups to link your Spotify account.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error linking Spotify: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function primeSpotify(deviceId) {
|
||||
const btn = document.getElementById('prime-spotify-' + deviceId);
|
||||
const originalText = btn.innerText;
|
||||
btn.innerText = 'Priming...';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/mgmt/spotify/prime?deviceId=${encodeURIComponent(deviceId)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (response.ok) {
|
||||
btn.innerText = '✅ Primed';
|
||||
btn.style.background = '#28a745';
|
||||
setTimeout(() => {
|
||||
btn.innerText = originalText;
|
||||
btn.style.background = '';
|
||||
btn.disabled = false;
|
||||
}, 3000);
|
||||
} else {
|
||||
const err = await response.text();
|
||||
alert('Failed to prime Spotify: ' + err);
|
||||
btn.innerText = '❌ Failed';
|
||||
setTimeout(() => {
|
||||
btn.innerText = originalText;
|
||||
btn.disabled = false;
|
||||
}, 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error priming Spotify: ' + error.message);
|
||||
btn.innerText = originalText;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSettings() {
|
||||
try {
|
||||
const response = await fetch('/setup/settings');
|
||||
@@ -144,40 +23,10 @@ async function fetchSettings() {
|
||||
if (settings.dns_bind_addr) {
|
||||
document.getElementById('dns-bind').value = settings.dns_bind_addr;
|
||||
}
|
||||
|
||||
const dnsCurrentUpstream = document.getElementById('dns-current-upstream');
|
||||
if (dnsCurrentUpstream && settings.dns_upstream) {
|
||||
dnsCurrentUpstream.innerText = 'Current upstreams: ' + settings.dns_upstream;
|
||||
} else if (dnsCurrentUpstream) {
|
||||
dnsCurrentUpstream.innerText = '';
|
||||
}
|
||||
|
||||
if (settings.mirror_enabled !== undefined) {
|
||||
document.getElementById('mirror-enabled').checked = settings.mirror_enabled;
|
||||
}
|
||||
if (settings.mirror_endpoints) {
|
||||
document.getElementById('mirror-endpoints').value = settings.mirror_endpoints.join('\n');
|
||||
}
|
||||
if (settings.internal_paths) {
|
||||
document.getElementById('internal-paths').value = settings.internal_paths.join('\n');
|
||||
}
|
||||
|
||||
if (settings.enable_soundcork_proxy !== undefined) {
|
||||
document.getElementById('enable-soundcork-proxy').checked = settings.enable_soundcork_proxy;
|
||||
}
|
||||
|
||||
const spotifyStatus = document.getElementById('spotify-config-status');
|
||||
if (spotifyStatus) {
|
||||
if (settings.spotify_configured) {
|
||||
spotifyStatus.innerHTML = '<span style="color: green;">✅ Configured</span> (Client ID present)';
|
||||
} else {
|
||||
spotifyStatus.innerHTML = '<span style="color: #666;">❌ Not Configured</span><br>' +
|
||||
'<span style="font-size: 0.85em; color: #888;">To enable Spotify, provide <code>SPOTIFY_CLIENT_ID</code> and <code>SPOTIFY_CLIENT_SECRET</code> to the server.</span>';
|
||||
}
|
||||
}
|
||||
|
||||
fetchProxySettings();
|
||||
fetchSpotifyStatus();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings', error);
|
||||
}
|
||||
@@ -225,9 +74,6 @@ async function updateSettings() {
|
||||
dns_enabled: document.getElementById('dns-enabled').checked,
|
||||
dns_upstream: document.getElementById('dns-upstream').value,
|
||||
dns_bind_addr: document.getElementById('dns-bind').value,
|
||||
mirror_enabled: document.getElementById('mirror-enabled').checked,
|
||||
mirror_endpoints: document.getElementById('mirror-endpoints').value.split('\n').map(s => s.trim()).filter(s => s !== ''),
|
||||
internal_paths: document.getElementById('internal-paths').value.split('\n').map(s => s.trim()).filter(s => s !== ''),
|
||||
enable_soundcork_proxy: document.getElementById('enable-soundcork-proxy').checked
|
||||
};
|
||||
const status = document.getElementById('settings-status');
|
||||
@@ -257,8 +103,9 @@ async function updateSettings() {
|
||||
|
||||
async function fetchDevices() {
|
||||
try {
|
||||
const response = await fetch('/setup/devices');
|
||||
const response = await fetch('/devices');
|
||||
const devices = await response.json();
|
||||
window._knownDevices = devices; // Store globally for easy lookup
|
||||
const container = document.getElementById('device-list');
|
||||
const syncSelector = document.getElementById('sync-device-list');
|
||||
const migrationSelector = document.getElementById('migration-device-list');
|
||||
@@ -281,7 +128,7 @@ async function fetchDevices() {
|
||||
devices.forEach(d => {
|
||||
const methodLabel = d.discovery_method === 'manual' ? '👤 Manual' : '🔍 Auto';
|
||||
html += `
|
||||
<tr id="device-row-${d.device_id}">
|
||||
<tr id="device-row-${d.ip_address.replace(/\./g, '-')}">
|
||||
<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-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>
|
||||
@@ -290,7 +137,6 @@ async function fetchDevices() {
|
||||
<td>
|
||||
<button onclick="prepareSync('${d.device_id}')">Sync Data</button>
|
||||
<button onclick="prepareMigration('${d.device_id}')">Migrate</button>
|
||||
<button id="prime-spotify-${d.device_id}" class="btn-spotify" style="display: none;" onclick="primeSpotify('${d.device_id}')">Prime Spotify</button>
|
||||
<button class="btn-danger" onclick="removeDevice('${d.device_id}', '${d.name}')">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -308,7 +154,7 @@ async function fetchDevices() {
|
||||
|
||||
if (eventSelector) {
|
||||
const optEvent = document.createElement('option');
|
||||
optEvent.value = d.device_id;
|
||||
optEvent.value = d.device_id || d.ip_address;
|
||||
optEvent.textContent = `${d.name} (${d.ip_address})`;
|
||||
eventSelector.appendChild(optEvent);
|
||||
}
|
||||
@@ -321,8 +167,7 @@ async function fetchDevices() {
|
||||
if (eventSelector && currentEventVal) eventSelector.value = currentEventVal;
|
||||
|
||||
// Asynchronously fetch live info for each device
|
||||
devices.forEach(d => updateDeviceInfo(d.device_id, d.ip_address));
|
||||
fetchSpotifyStatus();
|
||||
devices.forEach(d => updateDeviceInfo(d.device_id));
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('device-list').innerHTML = 'Error loading devices: ' + error;
|
||||
@@ -362,10 +207,6 @@ function openTab(evt, tabId) {
|
||||
fetchDNSDiscoveries();
|
||||
}
|
||||
|
||||
if (tabId === 'tab-parity') {
|
||||
fetchParityMismatches();
|
||||
}
|
||||
|
||||
if (evt) {
|
||||
evt.currentTarget.className += " active";
|
||||
} else {
|
||||
@@ -380,40 +221,25 @@ function openTab(evt, tabId) {
|
||||
}
|
||||
}
|
||||
|
||||
function getDeviceDisplayName(deviceId) {
|
||||
if (!deviceId) return "Unknown Device";
|
||||
|
||||
// 1. Try migration selector
|
||||
const migrationSelector = document.getElementById('migration-device-list');
|
||||
if (migrationSelector) {
|
||||
for (let opt of migrationSelector.options) {
|
||||
if (opt.value === deviceId && opt.textContent !== '-- Select a device --') {
|
||||
return opt.textContent;
|
||||
}
|
||||
function getDeviceLabel(deviceId) {
|
||||
if (window._knownDevices) {
|
||||
const d = window._knownDevices.find(dev => dev.device_id === deviceId);
|
||||
if (d) {
|
||||
return `${d.name} (${d.ip_address})`;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try sync selector
|
||||
const syncSelector = document.getElementById('sync-device-list');
|
||||
if (syncSelector) {
|
||||
for (let opt of syncSelector.options) {
|
||||
if (opt.value === deviceId && opt.textContent !== '-- Select a device --') {
|
||||
return opt.textContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try table lookup
|
||||
// Fallback to searching the UI
|
||||
const rows = document.querySelectorAll('#device-list tr');
|
||||
for (const row of rows) {
|
||||
const idCell = row.querySelector('.col-deviceid');
|
||||
if (idCell && idCell.innerText === deviceId) {
|
||||
const name = row.querySelector('.col-name').innerText;
|
||||
const ip = row.querySelector('.col-ip').innerText;
|
||||
return `${name} (${ip})`;
|
||||
for (let r of rows) {
|
||||
const deviceIdCol = r.querySelector('.col-deviceid');
|
||||
if (deviceIdCol && deviceIdCol.innerText === deviceId) {
|
||||
const nameEl = r.querySelector('.col-name');
|
||||
const ipEl = r.querySelector('.col-ip');
|
||||
if (nameEl && ipEl) {
|
||||
return `${nameEl.innerText} (${ipEl.innerText})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
@@ -424,46 +250,34 @@ async function startSync() {
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
const status = document.getElementById('sync-status');
|
||||
const results = document.getElementById('sync-results');
|
||||
const log = document.getElementById('sync-log');
|
||||
|
||||
status.style.display = 'block';
|
||||
status.style.backgroundColor = '#eef';
|
||||
const display = getDeviceDisplayName(deviceId);
|
||||
status.textContent = 'Syncing data from ' + display + '...';
|
||||
status.textContent = 'Syncing data from ' + deviceLabel + '...';
|
||||
results.style.display = 'none';
|
||||
log.innerHTML = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/sync/' + encodeURIComponent(deviceId), { method: 'POST' });
|
||||
const response = await fetch('/setup/devices/' + deviceId + '/sync', { method: 'POST' });
|
||||
if (response.ok) {
|
||||
status.style.backgroundColor = '#dfd';
|
||||
status.textContent = '✅ Sync completed successfully for ' + display + '!';
|
||||
status.textContent = '✅ Sync completed successfully!';
|
||||
results.style.display = 'block';
|
||||
log.innerHTML = 'Data fetched and saved to local datastore for ' + display + '.\nPresets: OK\nRecents: OK\nSources: OK';
|
||||
log.innerHTML = 'Data fetched and saved to local datastore.\nPresets: OK\nRecents: OK\nSources: OK';
|
||||
} else {
|
||||
const err = await response.text();
|
||||
throw new Error(err);
|
||||
}
|
||||
} catch (error) {
|
||||
status.style.backgroundColor = '#fdd';
|
||||
status.textContent = '❌ Sync failed for ' + display + ': ' + error.message;
|
||||
status.textContent = '❌ Sync failed: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVersion() {
|
||||
try {
|
||||
const response = await fetch('/setup/version');
|
||||
const data = await response.json();
|
||||
const info = document.getElementById('version-info');
|
||||
if (info && data.version) {
|
||||
info.innerText = `AfterTouch ${data.version} (${data.commit}) - ${data.date}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch version info', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchInteractionStats() {
|
||||
console.log('Fetching interaction stats...');
|
||||
@@ -783,10 +597,6 @@ async function clearDNSDiscoveries() {
|
||||
}
|
||||
}
|
||||
|
||||
function downloadDNSDiscoveries() {
|
||||
window.location.href = '/setup/dns-discoveries/download';
|
||||
}
|
||||
|
||||
async function showDeviceEvents() {
|
||||
const overlay = document.getElementById('device-events-overlay');
|
||||
overlay.style.display = 'block';
|
||||
@@ -807,7 +617,7 @@ async function fetchDeviceEvents(deviceId) {
|
||||
list.innerHTML = '<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Loading events...</td></tr>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/setup/devices/${deviceId}/events`);
|
||||
const response = await fetch(`/devices/${deviceId}/events`);
|
||||
const data = await response.json();
|
||||
const events = data.events;
|
||||
|
||||
@@ -840,110 +650,11 @@ async function fetchDeviceEvents(deviceId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchParityMismatches() {
|
||||
const list = document.getElementById('parity-mismatches-list');
|
||||
list.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #666;">Loading mismatches...</td></tr>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/parity-mismatches');
|
||||
const mismatches = await response.json();
|
||||
|
||||
list.innerHTML = '';
|
||||
if (!mismatches || mismatches.length === 0) {
|
||||
list.innerHTML = '<tr><td colspan="5" style="padding: 20px; text-align: center; color: #666;">No parity mismatches detected yet.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
mismatches.forEach(m => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.style.borderBottom = '1px solid #eee';
|
||||
|
||||
const time = m.timestamp || "";
|
||||
const method = m.method || "";
|
||||
const path = m.path || "";
|
||||
const reasons = (m.reasons || []).join(', ');
|
||||
|
||||
tr.innerHTML = `
|
||||
<td style="padding: 8px; font-size: 0.8em;">${time}</td>
|
||||
<td style="padding: 8px; font-family: monospace;">${method}</td>
|
||||
<td style="padding: 8px; font-size: 0.9em;">${path}</td>
|
||||
<td style="padding: 8px; font-size: 0.85em; color: #c62828;">${reasons}</td>
|
||||
<td style="padding: 8px;"><button onclick='viewParityMismatch(${JSON.stringify(m)})'>View Diff</button></td>
|
||||
`;
|
||||
list.appendChild(tr);
|
||||
});
|
||||
} catch (error) {
|
||||
list.innerHTML = `<tr><td colspan="5" style="padding: 20px; text-align: center; color: #f44336;">Error loading mismatches: ${error.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearParityMismatches() {
|
||||
if (!confirm('Are you sure you want to clear all parity mismatch records?')) return;
|
||||
try {
|
||||
await fetch('/setup/parity-mismatches', { method: 'DELETE' });
|
||||
fetchParityMismatches();
|
||||
document.getElementById('parity-diff-view').style.display = 'none';
|
||||
} catch (error) {
|
||||
alert('Failed to clear mismatches: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function viewParityMismatch(m) {
|
||||
document.getElementById('diff-path-display').innerText = m.method + ' ' + m.path;
|
||||
const reasonsList = document.getElementById('diff-reasons-list');
|
||||
reasonsList.innerHTML = '';
|
||||
(m.reasons || []).forEach(r => {
|
||||
const li = document.createElement('li');
|
||||
li.innerText = r;
|
||||
reasonsList.appendChild(li);
|
||||
});
|
||||
|
||||
document.getElementById('diff-local-meta').innerText = `Status: ${m.local.status}`;
|
||||
document.getElementById('diff-upstream-meta').innerText = `Status: ${m.upstream.status}`;
|
||||
|
||||
document.getElementById('diff-local-body').innerText = formatXML(m.local.body);
|
||||
document.getElementById('diff-upstream-body').innerText = formatXML(m.upstream.body);
|
||||
|
||||
document.getElementById('parity-diff-view').style.display = 'block';
|
||||
document.getElementById('parity-diff-view').scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function formatXML(xml) {
|
||||
if (!xml) return '';
|
||||
try {
|
||||
let formatted = '';
|
||||
let reg = /(>)(<)(\/*)/g;
|
||||
xml = xml.replace(reg, '$1\r\n$2$3');
|
||||
let pad = 0;
|
||||
xml.split('\r\n').forEach(function(node) {
|
||||
let indent = 0;
|
||||
if (node.match(/.+<\/\w[^>]*>$/)) {
|
||||
indent = 0;
|
||||
} else if (node.match(/^<\/\w/)) {
|
||||
if (pad !== 0) pad -= 1;
|
||||
} else if (node.match(/^<\w[^>]*[^\/]>.*$/)) {
|
||||
indent = 1;
|
||||
} else {
|
||||
indent = 0;
|
||||
}
|
||||
|
||||
let padding = '';
|
||||
for (let i = 0; i < pad; i++) padding += ' ';
|
||||
formatted += padding + node + '\r\n';
|
||||
pad += indent;
|
||||
});
|
||||
return formatted.trim();
|
||||
} catch (e) {
|
||||
return xml;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
fetchSettings();
|
||||
fetchDevices();
|
||||
triggerDiscovery();
|
||||
fetchVersion();
|
||||
fetchParityMismatches();
|
||||
|
||||
const syncBtn = document.getElementById('sync-now-btn');
|
||||
if (syncBtn) syncBtn.onclick = startSync;
|
||||
@@ -958,7 +669,7 @@ async function addManualDevice() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/devices', {
|
||||
const response = await fetch('/devices', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ip: ip })
|
||||
@@ -982,7 +693,7 @@ async function removeDevice(deviceId, name) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/setup/devices/${deviceId}`, {
|
||||
const response = await fetch(`/devices/${deviceId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
@@ -1026,14 +737,23 @@ async function pollDiscoveryStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDeviceInfo(deviceId, ip) {
|
||||
async function updateDeviceInfo(deviceId) {
|
||||
try {
|
||||
const response = await fetch('/setup/info/' + encodeURIComponent(deviceId));
|
||||
const response = await fetch('/devices/' + deviceId + '/info');
|
||||
if (!response.ok) return;
|
||||
const info = await response.json();
|
||||
|
||||
const rowId = 'device-row-' + deviceId;
|
||||
const row = document.getElementById(rowId);
|
||||
// Find the row by deviceId
|
||||
const rows = document.querySelectorAll('#device-list tr');
|
||||
let row = null;
|
||||
for (let r of rows) {
|
||||
const deviceIdCol = r.querySelector('.col-deviceid');
|
||||
if (deviceIdCol && deviceIdCol.innerText === deviceId) {
|
||||
row = r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (row) {
|
||||
const nameEl = row.querySelector('.col-name');
|
||||
if (nameEl && info.name) nameEl.innerText = info.name;
|
||||
@@ -1054,7 +774,7 @@ async function updateDeviceInfo(deviceId, ip) {
|
||||
if (accountIdEl && info.margeAccountUUID) accountIdEl.innerText = info.margeAccountUUID;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch live info for ' + ip, error);
|
||||
console.warn('Failed to fetch live info for ' + deviceId, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1073,23 +793,22 @@ async function showSummary(deviceId) {
|
||||
bmx: document.getElementById('opt-bmx').value
|
||||
};
|
||||
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
|
||||
const display = getDeviceDisplayName(deviceId);
|
||||
statusDiv.innerHTML = 'Fetching summary for ' + display + '...';
|
||||
|
||||
const outputBox = document.getElementById('command-output-box');
|
||||
if (outputBox) outputBox.style.display = 'none';
|
||||
statusDiv.innerHTML = 'Fetching summary for ' + deviceLabel + '...';
|
||||
|
||||
let query = '?target_url=' + encodeURIComponent(targetUrl) + '&proxy_url=' + encodeURIComponent(proxyUrl);
|
||||
for (let k in opts) {
|
||||
query += '&' + k + '=' + encodeURIComponent(opts[k]);
|
||||
}
|
||||
|
||||
const outputBox = document.getElementById('command-output-box');
|
||||
if (outputBox) outputBox.style.display = 'none';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/summary/' + encodeURIComponent(deviceId) + query);
|
||||
const response = await fetch('/setup/devices/' + deviceId + '/summary' + query);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText);
|
||||
@@ -1097,16 +816,18 @@ async function showSummary(deviceId) {
|
||||
const summary = await response.json();
|
||||
|
||||
statusDiv.style.display = 'none';
|
||||
document.getElementById('summary-ip').innerText = summary.device_id || deviceId;
|
||||
|
||||
const ip = summary.ip_address || deviceId;
|
||||
const finalDisplay = summary.device_name ? `${summary.device_name} (${ip})` : ip;
|
||||
document.getElementById('summary-device-display').innerText = finalDisplay;
|
||||
// Keep deviceId hidden for subsequent calls
|
||||
document.getElementById('summary-device-id').value = deviceId;
|
||||
|
||||
// Update table row if it exists
|
||||
const rowId = 'device-row-' + deviceId;
|
||||
const row = document.getElementById(rowId);
|
||||
// Find the row by deviceId
|
||||
const rows = document.querySelectorAll('#device-list tr');
|
||||
let row = null;
|
||||
for (let r of rows) {
|
||||
const deviceIdCol = r.querySelector('.col-deviceid');
|
||||
if (deviceIdCol && deviceIdCol.innerText === deviceId) {
|
||||
row = r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (row) {
|
||||
const nameEl = row.querySelector('.col-name');
|
||||
if (nameEl && summary.device_name) nameEl.innerText = summary.device_name;
|
||||
@@ -1168,7 +889,7 @@ async function showSummary(deviceId) {
|
||||
caTrustStatus.innerText = summary.ca_cert_trusted ? '✅ Yes' : '❌ No';
|
||||
caTrustStatus.style.color = summary.ca_cert_trusted ? 'green' : 'red';
|
||||
document.getElementById('trust-ca-btn').style.display = summary.ca_cert_trusted ? 'none' : 'inline-block';
|
||||
document.getElementById('trust-ca-btn').onclick = () => trustCA(deviceId, ip);
|
||||
document.getElementById('trust-ca-btn').onclick = () => trustCA(deviceId);
|
||||
} else {
|
||||
remoteStatus.innerText = '❓ Unknown';
|
||||
remoteStatus.style.color = 'gray';
|
||||
@@ -1206,41 +927,41 @@ async function showSummary(deviceId) {
|
||||
toggleMigrationMethod();
|
||||
|
||||
const migrateBtn = document.getElementById('confirm-migrate-btn');
|
||||
migrateBtn.onclick = () => migrate(deviceId, ip);
|
||||
migrateBtn.onclick = () => migrate(deviceId);
|
||||
migrateBtn.disabled = !summary.ssh_success;
|
||||
|
||||
const revertBtn = document.getElementById('revert-migrate-btn');
|
||||
revertBtn.onclick = () => revert(deviceId, ip);
|
||||
revertBtn.onclick = () => revert(deviceId);
|
||||
revertBtn.disabled = !summary.ssh_success;
|
||||
revertBtn.style.display = summary.original_config ? 'inline-block' : 'none';
|
||||
|
||||
const rebootBtn = document.getElementById('reboot-speaker-btn');
|
||||
rebootBtn.onclick = () => reboot(deviceId, ip);
|
||||
rebootBtn.onclick = () => reboot(deviceId);
|
||||
rebootBtn.disabled = !summary.ssh_success;
|
||||
rebootBtn.style.border = 'none'; // Reset border if it was set during migration
|
||||
|
||||
const remoteBtn = document.getElementById('ensure-remote-btn');
|
||||
remoteBtn.onclick = () => ensureRemoteServices(deviceId, ip);
|
||||
remoteBtn.onclick = () => ensureRemoteServices(deviceId);
|
||||
remoteBtn.disabled = !summary.ssh_success;
|
||||
|
||||
const removeRemoteBtn = document.getElementById('remove-remote-btn');
|
||||
removeRemoteBtn.onclick = () => removeRemoteServices(deviceId, ip);
|
||||
removeRemoteBtn.onclick = () => removeRemoteServices(deviceId);
|
||||
removeRemoteBtn.disabled = !summary.ssh_success || !summary.remote_services_enabled;
|
||||
|
||||
const backupBtn = document.getElementById('backup-config-btn');
|
||||
backupBtn.onclick = () => backupConfig(deviceId, ip);
|
||||
backupBtn.onclick = () => backupConfig(deviceId);
|
||||
backupBtn.disabled = !summary.ssh_success || !!summary.original_config;
|
||||
|
||||
document.getElementById('migration-summary').style.display = 'block';
|
||||
document.getElementById('migration-summary').scrollIntoView();
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error fetching summary for ' + display + ': ' + error;
|
||||
statusDiv.innerHTML = 'Error fetching summary for ' + deviceId + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSummary() {
|
||||
const deviceId = document.getElementById('summary-device-id').value;
|
||||
const deviceId = document.getElementById('summary-ip').innerText;
|
||||
if (deviceId) {
|
||||
showSummary(deviceId);
|
||||
}
|
||||
@@ -1257,13 +978,13 @@ function showCommandOutput(result) {
|
||||
}
|
||||
}
|
||||
|
||||
async function revert(deviceId, ip) {
|
||||
async function revert(deviceId) {
|
||||
if (!deviceId) {
|
||||
alert('Please select a device.');
|
||||
return;
|
||||
}
|
||||
const display = getDeviceDisplayName(deviceId);
|
||||
if (!confirm('Are you sure you want to revert ' + display + ' to Bose cloud defaults?')) {
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
if (!confirm('Are you sure you want to revert ' + deviceLabel + ' to Bose cloud defaults?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1273,62 +994,63 @@ async function revert(deviceId, ip) {
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Reverting ' + display + ' to defaults...';
|
||||
statusDiv.innerHTML = 'Reverting ' + deviceLabel + ' to defaults...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/revert/' + encodeURIComponent(deviceId), { method: 'POST' });
|
||||
const response = await fetch('/setup/devices/' + deviceId + '/revert', { method: 'POST' });
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully started revert for ' + display + '.';
|
||||
statusDiv.innerHTML = 'Successfully started revert for ' + deviceLabel + '.';
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Revert failed for ' + display + ': ' + (result.message || 'Unknown error');
|
||||
statusDiv.innerHTML = 'Revert failed for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error reverting ' + display + ': ' + error;
|
||||
statusDiv.innerHTML = 'Error reverting ' + deviceLabel + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function reboot(deviceId, ip) {
|
||||
async function reboot(deviceId) {
|
||||
if (!deviceId) {
|
||||
alert('Please select a device.');
|
||||
return;
|
||||
}
|
||||
const display = getDeviceDisplayName(deviceId);
|
||||
if (!confirm('Are you sure you want to reboot the speaker at ' + display + '?')) {
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
if (!confirm('Are you sure you want to reboot the speaker ' + deviceLabel + '?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Rebooting ' + display + '...';
|
||||
statusDiv.innerHTML = 'Rebooting ' + deviceLabel + '...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/reboot/' + encodeURIComponent(deviceId), { method: 'POST' });
|
||||
const response = await fetch('/devices/' + deviceId + '/reboot', { method: 'POST' });
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully started reboot for ' + display + '.';
|
||||
statusDiv.innerHTML = 'Successfully started reboot for ' + deviceLabel + '.';
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Reboot failed for ' + display + ': ' + (result.message || 'Unknown error');
|
||||
statusDiv.innerHTML = 'Reboot failed for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error rebooting ' + display + ': ' + error;
|
||||
statusDiv.innerHTML = 'Error rebooting ' + deviceLabel + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function migrate(deviceId, ip) {
|
||||
async function migrate(deviceId) {
|
||||
if (!deviceId) {
|
||||
alert('Please select a device.');
|
||||
return;
|
||||
}
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
const targetUrl = document.getElementById('target-domain').value;
|
||||
const proxyUrl = document.getElementById('soundcork-url').value;
|
||||
const method = document.getElementById('migration-method').value;
|
||||
@@ -1346,8 +1068,7 @@ async function migrate(deviceId, ip) {
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
const display = getDeviceDisplayName(deviceId);
|
||||
statusDiv.innerHTML = 'Migrating ' + display + ' using ' + method + '...';
|
||||
statusDiv.innerHTML = 'Migrating ' + deviceLabel + ' using ' + method + '...';
|
||||
|
||||
let query = '?method=' + encodeURIComponent(method) + '&target_url=' + encodeURIComponent(targetUrl) + '&proxy_url=' + encodeURIComponent(proxyUrl);
|
||||
for (let k in opts) {
|
||||
@@ -1355,167 +1076,169 @@ async function migrate(deviceId, ip) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/migrate/' + encodeURIComponent(deviceId) + query, { method: 'POST' });
|
||||
const response = await fetch('/setup/devices/' + deviceId + '/migrate' + query, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully started migration for ' + display + '. <strong>Please reboot the device to activate the changes.</strong>';
|
||||
statusDiv.innerHTML = 'Successfully started migration for ' + deviceLabel + '. <strong>Please reboot the device to activate the changes.</strong>';
|
||||
|
||||
// Make reboot button available and prominent
|
||||
const rebootBtn = document.getElementById('reboot-speaker-btn');
|
||||
rebootBtn.style.display = 'inline-block';
|
||||
rebootBtn.disabled = false;
|
||||
rebootBtn.style.border = '2px solid #000';
|
||||
rebootBtn.onclick = () => reboot(deviceId);
|
||||
|
||||
// Re-show summary but with prominence on reboot
|
||||
summaryDiv.style.display = 'block';
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Migration failed for ' + display + ': ' + (result.message || 'Unknown error');
|
||||
statusDiv.innerHTML = 'Migration failed for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error migrating ' + display + ': ' + error;
|
||||
statusDiv.innerHTML = 'Error migrating ' + deviceLabel + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function trustCA(deviceId, ip) {
|
||||
async function trustCA(deviceId) {
|
||||
if (!deviceId) {
|
||||
alert('Please select a device.');
|
||||
return;
|
||||
}
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
const display = getDeviceDisplayName(deviceId);
|
||||
statusDiv.innerHTML = 'Injecting Root CA into shared trust store on ' + display + '...';
|
||||
statusDiv.innerHTML = 'Injecting Root CA into shared trust store on ' + deviceLabel + '...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/trust-ca/' + encodeURIComponent(deviceId), { method: 'POST' });
|
||||
const response = await fetch('/setup/devices/' + deviceId + '/trust-ca', { method: 'POST' });
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully injected Root CA on ' + display + '.';
|
||||
statusDiv.innerHTML = 'Successfully injected Root CA on ' + deviceLabel + '.';
|
||||
showSummary(deviceId); // Refresh to update status
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Failed to trust CA on ' + display + ': ' + (result.message || 'Unknown error');
|
||||
statusDiv.innerHTML = 'Failed to trust CA on ' + deviceLabel + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error trusting CA on ' + display + ': ' + error;
|
||||
statusDiv.innerHTML = 'Error trusting CA on ' + deviceLabel + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureRemoteServices(deviceId, ip) {
|
||||
async function ensureRemoteServices(deviceId) {
|
||||
if (!deviceId) {
|
||||
alert('Please select a device.');
|
||||
return;
|
||||
}
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
const summaryDiv = document.getElementById('migration-summary');
|
||||
summaryDiv.style.display = 'none';
|
||||
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Ensuring remote services for ' + deviceLabel + '...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/devices/' + deviceId + '/ensure-remote-services', { method: 'POST' });
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully ensured remote services for ' + deviceLabel + '.';
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Failed to ensure remote services for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error ensuring remote services for ' + deviceLabel + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRemoteServices(deviceId) {
|
||||
if (!deviceId) {
|
||||
alert('Please select a device.');
|
||||
return;
|
||||
}
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
if (!confirm('Are you sure you want to remove remote services from ' + deviceLabel + '?')) {
|
||||
return;
|
||||
}
|
||||
const summaryDiv = document.getElementById('migration-summary');
|
||||
summaryDiv.style.display = 'none';
|
||||
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
const display = getDeviceDisplayName(deviceId);
|
||||
statusDiv.innerHTML = 'Ensuring remote services for ' + display + '...';
|
||||
statusDiv.innerHTML = 'Removing remote services for ' + deviceLabel + '...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/ensure-remote-services/' + encodeURIComponent(deviceId), { method: 'POST' });
|
||||
const response = await fetch('/setup/devices/' + deviceId + '/remove-remote-services', { method: 'POST' });
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully ensured remote services for ' + display + '.';
|
||||
statusDiv.innerHTML = 'Successfully removed remote services from ' + deviceLabel + '.';
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Failed to ensure remote services for ' + display + ': ' + (result.message || 'Unknown error');
|
||||
statusDiv.innerHTML = 'Failed to remove remote services for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error ensuring remote services for ' + display + ': ' + error;
|
||||
statusDiv.innerHTML = 'Error removing remote services for ' + deviceLabel + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRemoteServices(deviceId, ip) {
|
||||
async function backupConfig(deviceId) {
|
||||
if (!deviceId) {
|
||||
alert('Please select a device.');
|
||||
return;
|
||||
}
|
||||
const display = getDeviceDisplayName(deviceId);
|
||||
if (!confirm('Are you sure you want to remove remote services from ' + display + '?')) {
|
||||
return;
|
||||
}
|
||||
const summaryDiv = document.getElementById('migration-summary');
|
||||
summaryDiv.style.display = 'none';
|
||||
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
statusDiv.innerHTML = 'Removing remote services for ' + display + '...';
|
||||
statusDiv.innerHTML = 'Creating backup for ' + deviceLabel + '...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/remove-remote-services/' + encodeURIComponent(deviceId), { method: 'POST' });
|
||||
const response = await fetch('/setup/devices/' + deviceId + '/backup', { method: 'POST' });
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully removed remote services from ' + display + '.';
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Failed to remove remote services for ' + display + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error removing remote services for ' + display + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function backupConfig(deviceId, ip) {
|
||||
if (!deviceId) {
|
||||
alert('Please select a device.');
|
||||
return;
|
||||
}
|
||||
const statusDiv = document.getElementById('status');
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.style.backgroundColor = '#ffffcc';
|
||||
const display = getDeviceDisplayName(deviceId);
|
||||
statusDiv.innerHTML = 'Creating backup for ' + display + '...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/backup/' + encodeURIComponent(deviceId), { method: 'POST' });
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = '#ccffcc';
|
||||
statusDiv.innerHTML = 'Successfully created backup for ' + display + '.';
|
||||
statusDiv.innerHTML = 'Successfully created backup for ' + deviceLabel + '.';
|
||||
showSummary(deviceId); // Refresh
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Backup failed for ' + display + ': ' + (result.message || 'Unknown error');
|
||||
statusDiv.innerHTML = 'Failed to create backup for ' + deviceLabel + ': ' + (result.message || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = '#ffcccc';
|
||||
statusDiv.innerHTML = 'Error creating backup for ' + display + ': ' + error;
|
||||
statusDiv.innerHTML = 'Error creating backup for ' + deviceLabel + ': ' + error;
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection(deviceId, useExplicitCA) {
|
||||
const testUrl = document.getElementById('test-url').innerText;
|
||||
const testResultDiv = document.getElementById('test-result');
|
||||
const display = getDeviceDisplayName(deviceId);
|
||||
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
|
||||
testResultDiv.style.display = 'block';
|
||||
testResultDiv.style.backgroundColor = '#f0f0f0';
|
||||
testResultDiv.style.color = 'black';
|
||||
testResultDiv.innerText = 'Running connection test from ' + display + '...\n(This may take a few seconds)';
|
||||
testResultDiv.innerText = 'Running connection test from ' + deviceLabel + '...\n(This may take a few seconds)';
|
||||
|
||||
try {
|
||||
const query = `?target_url=${encodeURIComponent(testUrl)}&use_explicit_ca=${useExplicitCA}`;
|
||||
const response = await fetch(`/setup/test-connection/${encodeURIComponent(deviceId)}${query}`, { method: 'POST' });
|
||||
const response = await fetch(`/setup/devices/${deviceId}/test-connection${query}`, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
@@ -1534,16 +1257,17 @@ async function testConnection(deviceId, useExplicitCA) {
|
||||
async function testHostsRedirection(deviceId) {
|
||||
const targetUrl = document.getElementById('target-domain').value;
|
||||
const testResultDiv = document.getElementById('hosts-test-result');
|
||||
const display = getDeviceDisplayName(deviceId);
|
||||
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
|
||||
testResultDiv.style.display = 'block';
|
||||
testResultDiv.style.backgroundColor = '#f0f0f0';
|
||||
testResultDiv.style.color = 'black';
|
||||
testResultDiv.innerText = 'Running hosts redirection test from ' + display + '...\n(This may take a few seconds)';
|
||||
testResultDiv.innerText = 'Running hosts redirection test from ' + deviceLabel + '...\n(This may take a few seconds)';
|
||||
|
||||
try {
|
||||
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
|
||||
const response = await fetch(`/setup/test-hosts/${encodeURIComponent(deviceId)}${query}`, { method: 'POST' });
|
||||
const response = await fetch(`/setup/devices/${deviceId}/test-hosts${query}`, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
@@ -1562,16 +1286,17 @@ async function testHostsRedirection(deviceId) {
|
||||
async function testDNSRedirection(deviceId) {
|
||||
const targetUrl = document.getElementById('target-domain').value;
|
||||
const testResultDiv = document.getElementById('dns-test-result');
|
||||
const display = getDeviceDisplayName(deviceId);
|
||||
|
||||
const deviceLabel = getDeviceLabel(deviceId);
|
||||
|
||||
testResultDiv.style.display = 'block';
|
||||
testResultDiv.style.backgroundColor = '#f0f0f0';
|
||||
testResultDiv.style.color = 'black';
|
||||
testResultDiv.innerText = 'Running DNS redirection test from ' + display + '...\n(This may take a few seconds)';
|
||||
testResultDiv.innerText = 'Running DNS redirection test from ' + deviceLabel + '...\n(This may take a few seconds)';
|
||||
|
||||
try {
|
||||
const query = `?target_url=${encodeURIComponent(targetUrl)}`;
|
||||
const response = await fetch(`/setup/test-dns/${encodeURIComponent(deviceId)}${query}`, { method: 'POST' });
|
||||
const response = await fetch(`/setup/devices/${deviceId}/test-dns${query}`, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
@@ -100,47 +100,9 @@ pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px;
|
||||
}
|
||||
.category-self { background-color: #e3f2fd; color: #0d47a1; }
|
||||
.category-upstream { background-color: #f3e5f5; color: #7b1fa2; }
|
||||
.category-mirror { background-color: #fff3e0; color: #e65100; }
|
||||
.status-success { background-color: #e8f5e9; color: #2e7d32; }
|
||||
.status-error { background-color: #ffebee; color: #c62828; }
|
||||
|
||||
.info-toggle {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
background-color: #607D8B;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
margin-left: 5px;
|
||||
font-style: normal;
|
||||
user-select: none;
|
||||
}
|
||||
.info-toggle:hover {
|
||||
background-color: #455A64;
|
||||
}
|
||||
.info-details {
|
||||
display: none;
|
||||
background-color: #f0f7ff;
|
||||
border: 1px solid #d0e0f0;
|
||||
padding: 10px;
|
||||
margin-top: 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
max-width: 400px;
|
||||
}
|
||||
.info-details code {
|
||||
background-color: #e3f2fd;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
@@ -0,0 +1,16 @@
|
||||
footer {
|
||||
margin-top: 50px;
|
||||
padding: 20px;
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#version-info a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#version-info a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
async function fetchVersion() {
|
||||
try {
|
||||
const response = await fetch('/version');
|
||||
const data = await response.json();
|
||||
const info = document.getElementById('version-info');
|
||||
if (info && data.version) {
|
||||
const version = data.version;
|
||||
const commit = data.commit;
|
||||
const isDirty = version.includes('dirty');
|
||||
const releaseUrl = isDirty
|
||||
? 'https://github.com/gesellix/Bose-SoundTouch/releases'
|
||||
: `https://github.com/gesellix/Bose-SoundTouch/releases/tag/v${version}`;
|
||||
const commitUrl = `https://github.com/gesellix/Bose-SoundTouch/commit/${commit}`;
|
||||
const projectUrl = 'https://gesellix.github.io/Bose-SoundTouch/';
|
||||
|
||||
info.innerHTML = `<a href="${projectUrl}" target="_blank" style="color: inherit; text-decoration: none;">AfterTouch</a> ` +
|
||||
`<a href="${releaseUrl}" target="_blank" style="color: inherit;">${version}</a> ` +
|
||||
`(<a href="${commitUrl}" target="_blank" style="color: inherit;">${commit}</a>) - ${data.date}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch version info', error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
async function fetchDevices() {
|
||||
try {
|
||||
const response = await fetch('/devices');
|
||||
const devices = await response.json();
|
||||
const container = document.getElementById('device-list');
|
||||
const seen = new Set();
|
||||
|
||||
if (devices.length === 0) {
|
||||
container.innerHTML = '<p>No devices found. Ensure they are on the same network.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
devices.forEach(device => {
|
||||
seen.add(device.device_id);
|
||||
const existing = document.getElementById(`device-${device.device_id}`);
|
||||
if (existing) {
|
||||
// Update product code/IP if changed, but keep title if we already have a better name
|
||||
const title = existing.querySelector('.device-title');
|
||||
if (title && (!title.textContent || title.textContent === 'Unknown Device' || title.textContent.startsWith('SoundTouch-'))) {
|
||||
title.textContent = device.name || 'Unknown Device';
|
||||
}
|
||||
const subtitle = existing.querySelector('.device-subtitle span');
|
||||
if (subtitle) {
|
||||
const currentSubtitle = subtitle.textContent || '';
|
||||
const parts = currentSubtitle.split(' | ');
|
||||
const currentType = parts.length > 1 ? parts[1].trim() : '';
|
||||
const newType = device.product_code || 'Unknown';
|
||||
|
||||
// Don't downgrade type if we already have a specific one
|
||||
const isGeneric = !currentType || currentType === 'Unknown' || currentType === 'N/A';
|
||||
const displayType = isGeneric ? newType : currentType;
|
||||
subtitle.textContent = `${device.ip_address} | ${displayType}`;
|
||||
}
|
||||
const details = existing.querySelector(`#details-${device.device_id}`);
|
||||
if (details) {
|
||||
const idField = details.querySelector('p:nth-child(1) code');
|
||||
if (idField) {
|
||||
const currentId = idField.textContent;
|
||||
// Don't overwrite with serial if we have a real deviceID (usually hex)
|
||||
if (!currentId || currentId === 'N/A' || currentId === device.device_serial_number) {
|
||||
idField.textContent = device.device_id || 'N/A';
|
||||
}
|
||||
}
|
||||
const firmwareField = details.querySelector('p:nth-child(2) code');
|
||||
if (firmwareField) {
|
||||
const cur = firmwareField.textContent;
|
||||
if (!cur || cur === 'N/A' || cur === '0.0.0') {
|
||||
firmwareField.textContent = device.firmware_version || 'N/A';
|
||||
}
|
||||
}
|
||||
const serialField = details.querySelector('p:nth-child(3) code');
|
||||
if (serialField && (!serialField.textContent || serialField.textContent === 'N/A')) {
|
||||
serialField.textContent = device.device_serial_number || 'N/A';
|
||||
}
|
||||
}
|
||||
// Ensure WS is open
|
||||
openDeviceWebSocket(device.device_id);
|
||||
return;
|
||||
}
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'device-card';
|
||||
card.id = `device-${device.device_id}`;
|
||||
card.innerHTML = `
|
||||
<div class="device-info">
|
||||
<div class="device-header">
|
||||
<div>
|
||||
<div class="device-title-row">
|
||||
<h2 class="device-title">${device.name || 'Unknown Device'}</h2>
|
||||
<button class="info-toggle" title="More info" onclick="toggleDetails('${device.device_id}')">i</button>
|
||||
</div>
|
||||
<p class="device-subtitle">
|
||||
<span>${device.ip_address} | ${device.product_code}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button class="power-icon" title="Power" aria-label="Power" onclick="control('${device.device_id}', 'POWER')"></button>
|
||||
</div>
|
||||
<div class="device-details" id="details-${device.device_id}">
|
||||
<p>ID: <code>${device.device_id}</code></p>
|
||||
<p>Firmware: <code>${device.firmware_version || 'N/A'}</code></p>
|
||||
<p>Serial: <code>${device.device_serial_number || 'N/A'}</code></p>
|
||||
<p>Discovery: <code>${device.discovery_method || 'N/A'}</code></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="now-playing" id="np-${device.device_id}">
|
||||
<p><em>Loading playback status...</em></p>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button class="primary" onclick="control('${device.device_id}', 'PLAY')">Play</button>
|
||||
<button class="primary" onclick="control('${device.device_id}', 'PAUSE')">Pause</button>
|
||||
<button onclick="control('${device.device_id}', 'PREV_TRACK')">Prev</button>
|
||||
<button onclick="control('${device.device_id}', 'NEXT_TRACK')">Next</button>
|
||||
</div>
|
||||
<div class="volume-container">
|
||||
<span>Vol:</span>
|
||||
<input id="vol-${device.device_id}" type="range" min="0" max="100"
|
||||
oninput="onVolumeInput('${device.device_id}', this)"
|
||||
onmousedown="startAdjust('${device.device_id}')" ontouchstart="startAdjust('${device.device_id}')"
|
||||
onmouseup="endAdjust('${device.device_id}')" ontouchend="endAdjust('${device.device_id}')">
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(card);
|
||||
updateNowPlaying(device.device_id);
|
||||
updateVolume(device.device_id);
|
||||
openDeviceWebSocket(device.device_id);
|
||||
});
|
||||
|
||||
// Remove cards for devices that no longer exist
|
||||
Array.from(container.children).forEach(child => {
|
||||
const id = child.id?.replace('device-', '');
|
||||
if (id && !seen.has(id)) {
|
||||
container.removeChild(child);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch devices', error);
|
||||
document.getElementById('device-list').innerHTML = '<p>Error loading devices.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function updateNowPlaying(deviceId) {
|
||||
try {
|
||||
const response = await fetch(`/devices/${deviceId}/info`);
|
||||
if (!response.ok) return;
|
||||
const info = await response.json();
|
||||
|
||||
// Update device name and type if available (live info is more accurate than discovery)
|
||||
const title = document.querySelector(`#device-${deviceId} .device-title`);
|
||||
if (title && info.name) {
|
||||
title.textContent = info.name;
|
||||
}
|
||||
const subtitle = document.querySelector(`#device-${deviceId} .device-subtitle span`);
|
||||
if (subtitle && info.type) {
|
||||
subtitle.textContent = `${info.ipAddress || info.ip_address || 'N/A'} | ${info.type}`;
|
||||
}
|
||||
|
||||
// Update firmware version if available
|
||||
const details = document.getElementById(`details-${deviceId}`);
|
||||
if (details) {
|
||||
if (info.deviceID) {
|
||||
const idField = details.querySelector('p:nth-child(1) code');
|
||||
if (idField) idField.textContent = info.deviceID;
|
||||
}
|
||||
if (info.softwareVersion) {
|
||||
const firmwareField = details.querySelector('p:nth-child(2) code');
|
||||
if (firmwareField) firmwareField.textContent = info.softwareVersion;
|
||||
}
|
||||
if (info.serialNumber) {
|
||||
const serialField = details.querySelector('p:nth-child(3) code');
|
||||
if (serialField) serialField.textContent = info.serialNumber;
|
||||
}
|
||||
}
|
||||
|
||||
const npContainer = document.getElementById(`np-${deviceId}`);
|
||||
if (npContainer && info.nowPlaying) {
|
||||
const np = info.nowPlaying;
|
||||
const source = np.source || np.Source;
|
||||
const powerIcon = document.querySelector(`#device-${deviceId} .power-icon`);
|
||||
if (powerIcon) {
|
||||
if (source === 'STANDBY') {
|
||||
powerIcon.classList.add('off');
|
||||
powerIcon.classList.remove('on');
|
||||
} else {
|
||||
powerIcon.classList.remove('off');
|
||||
powerIcon.classList.add('on');
|
||||
}
|
||||
}
|
||||
if (source === 'STANDBY') {
|
||||
npContainer.innerHTML = '<div class="now-playing-info"><p><em>Standby</em></p></div>';
|
||||
} else {
|
||||
const track = np.track || np.Track || np.stationName || np.StationName || 'Unknown Track';
|
||||
const artist = np.artist || np.Artist || 'Unknown Artist';
|
||||
const album = np.album || np.Album || 'Unknown Album';
|
||||
const art = np.Art || np.art || {};
|
||||
const artStatus = art.ArtImageStatus || art.artImageStatus;
|
||||
const artUrl = artStatus === 'IMAGE_PRESENT' ? (art.URL || art.url || '') : '';
|
||||
|
||||
npContainer.innerHTML = `
|
||||
<img class="album-art" src="${artUrl}" alt="Artwork">
|
||||
<div class="now-playing-info">
|
||||
<strong>${track}</strong><br>
|
||||
${artist} - ${album}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch now playing for ' + deviceId, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateVolume(deviceId) {
|
||||
try {
|
||||
const response = await fetch(`/devices/${deviceId}/info`);
|
||||
if (!response.ok) return;
|
||||
const info = await response.json();
|
||||
const slider = document.getElementById(`vol-${deviceId}`);
|
||||
if (slider && info.volume && typeof info.volume.actualvolume === 'number' && !adjusting[deviceId]) {
|
||||
slider.value = String(info.volume.actualvolume);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch volume for ' + deviceId, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function control(deviceId, key) {
|
||||
let deviceName = deviceId;
|
||||
const title = document.querySelector(`#device-${deviceId} .device-title`);
|
||||
if (title && title.textContent) {
|
||||
deviceName = title.textContent;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/devices/${encodeURIComponent(deviceId)}/key/${encodeURIComponent(key)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Control failed', error);
|
||||
alert(`Failed to send ${key} to ${deviceName}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function setVolume(deviceId, level) {
|
||||
let deviceName = deviceId;
|
||||
const title = document.querySelector(`#device-${deviceId} .device-title`);
|
||||
if (title && title.textContent) {
|
||||
deviceName = title.textContent;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/devices/${encodeURIComponent(deviceId)}/volume/${encodeURIComponent(level)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Set volume failed', error);
|
||||
alert(`Failed to set volume on ${deviceName} to ${level}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Volume interaction helpers to avoid UI jumping while dragging
|
||||
const adjusting = {};
|
||||
const volumeTimers = {};
|
||||
|
||||
function startAdjust(deviceId) {
|
||||
adjusting[deviceId] = true;
|
||||
}
|
||||
|
||||
function endAdjust(deviceId) {
|
||||
// Small delay to let the device send back its volume update
|
||||
setTimeout(() => { adjusting[deviceId] = false; }, 300);
|
||||
}
|
||||
|
||||
function onVolumeInput(deviceId, el) {
|
||||
startAdjust(deviceId);
|
||||
const level = el.value;
|
||||
// Debounce network calls per device
|
||||
if (volumeTimers[deviceId]) {
|
||||
clearTimeout(volumeTimers[deviceId]);
|
||||
}
|
||||
volumeTimers[deviceId] = setTimeout(() => {
|
||||
setVolume(deviceId, level);
|
||||
endAdjust(deviceId);
|
||||
}, 150);
|
||||
}
|
||||
|
||||
let deviceSockets = {};
|
||||
|
||||
function openDeviceWebSocket(deviceId) {
|
||||
const key = `${deviceId}`;
|
||||
try {
|
||||
const existing = deviceSockets[key];
|
||||
if (existing) {
|
||||
// Reuse an already healthy connection instead of tearing it down every refresh
|
||||
if (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING) {
|
||||
return;
|
||||
}
|
||||
try { existing.close(); } catch (_) {}
|
||||
}
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const wsUrl = `${proto}://${location.host}/devices/${encodeURIComponent(deviceId)}/ws`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
deviceSockets[key] = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
// console.log('WS connected for', deviceId);
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
const type = msg.type;
|
||||
const payload = msg.payload || {};
|
||||
if (type === 'nowPlayingUpdated') {
|
||||
const e = payload;
|
||||
const np = e.NowPlaying || e.nowPlaying || {};
|
||||
const source = np.source || np.Source;
|
||||
|
||||
// Also try to update name/type if they are present in the event (sometimes events carry device info)
|
||||
const title = document.querySelector(`#device-${deviceId} .device-title`);
|
||||
if (title && e.name) {
|
||||
title.textContent = e.name;
|
||||
}
|
||||
const subtitle = document.querySelector(`#device-${deviceId} .device-subtitle span`);
|
||||
if (subtitle && e.type) {
|
||||
subtitle.textContent = `${e.ipAddress || e.ip_address || 'N/A'} | ${e.type}`;
|
||||
}
|
||||
|
||||
const powerIcon = document.querySelector(`#device-${deviceId} .power-icon`);
|
||||
if (powerIcon) {
|
||||
if (source === 'STANDBY') {
|
||||
powerIcon.classList.add('off');
|
||||
powerIcon.classList.remove('on');
|
||||
} else {
|
||||
powerIcon.classList.remove('off');
|
||||
powerIcon.classList.add('on');
|
||||
}
|
||||
}
|
||||
const npContainer = document.getElementById(`np-${deviceId}`);
|
||||
if (npContainer) {
|
||||
if (source === 'STANDBY') {
|
||||
npContainer.innerHTML = '<div class="now-playing-info"><p><em>Standby</em></p></div>';
|
||||
} else {
|
||||
const track = np.track || np.Track || np.stationName || np.StationName || 'Unknown Track';
|
||||
const artist = np.artist || np.Artist || 'Unknown Artist';
|
||||
const album = np.album || np.Album || 'Unknown Album';
|
||||
const art = np.Art || np.art || {};
|
||||
const artStatus = art.ArtImageStatus || art.artImageStatus;
|
||||
const artUrl = artStatus === 'IMAGE_PRESENT' ? (art.URL || art.url || '') : '';
|
||||
|
||||
npContainer.innerHTML = `
|
||||
<img class="album-art" src="${artUrl}" alt="Artwork">
|
||||
<div class="now-playing-info">
|
||||
<strong>${track}</strong><br>
|
||||
${artist} - ${album}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
} else if (type === 'volumeUpdated') {
|
||||
const e = payload;
|
||||
const vol = (e.Volume && (typeof e.Volume.actualvolume === 'number' ? e.Volume.actualvolume : (typeof e.Volume.actual === 'number' ? e.Volume.actual : e.Volume.target))) ||
|
||||
(e.volume && (typeof e.volume.actualvolume === 'number' ? e.volume.actualvolume : (typeof e.volume.actual === 'number' ? e.volume.actual : e.volume.target)));
|
||||
const slider = document.getElementById(`vol-${deviceId}`);
|
||||
if (slider && typeof vol === 'number' && !adjusting[deviceId]) {
|
||||
slider.value = String(vol);
|
||||
}
|
||||
} else if (type === 'snapshotInfo') {
|
||||
const info = payload || {};
|
||||
|
||||
// Update name and type from snapshot
|
||||
const title = document.querySelector(`#device-${deviceId} .device-title`);
|
||||
if (title && info.name) {
|
||||
title.textContent = info.name;
|
||||
}
|
||||
const subtitle = document.querySelector(`#device-${deviceId} .device-subtitle span`);
|
||||
if (subtitle && info.type) {
|
||||
subtitle.textContent = `${info.ipAddress || info.ip_address || 'N/A'} | ${info.type}`;
|
||||
}
|
||||
|
||||
// Update firmware and ID from snapshot if available
|
||||
const details = document.getElementById(`details-${deviceId}`);
|
||||
if (details) {
|
||||
if (info.deviceID) {
|
||||
const idField = details.querySelector('p:nth-child(1) code');
|
||||
if (idField) idField.textContent = info.deviceID;
|
||||
}
|
||||
if (info.softwareVersion) {
|
||||
const firmwareField = details.querySelector('p:nth-child(2) code');
|
||||
if (firmwareField) firmwareField.textContent = info.softwareVersion;
|
||||
}
|
||||
if (info.serialNumber) {
|
||||
const serialField = details.querySelector('p:nth-child(3) code');
|
||||
if (serialField) serialField.textContent = info.serialNumber;
|
||||
}
|
||||
}
|
||||
|
||||
if (info.nowPlaying) {
|
||||
const np = info.nowPlaying;
|
||||
const source = np.source || np.Source;
|
||||
const powerIcon = document.querySelector(`#device-${deviceId} .power-icon`);
|
||||
if (powerIcon) {
|
||||
if (source === 'STANDBY') {
|
||||
powerIcon.classList.add('off');
|
||||
powerIcon.classList.remove('on');
|
||||
} else {
|
||||
powerIcon.classList.remove('off');
|
||||
powerIcon.classList.add('on');
|
||||
}
|
||||
}
|
||||
const npContainer = document.getElementById(`np-${deviceId}`);
|
||||
if (npContainer) {
|
||||
if (source === 'STANDBY') {
|
||||
npContainer.innerHTML = '<div class="now-playing-info"><p><em>Standby</em></p></div>';
|
||||
} else {
|
||||
const track = np.track || np.Track || np.stationName || np.StationName || 'Unknown Track';
|
||||
const artist = np.artist || np.Artist || 'Unknown Artist';
|
||||
const album = np.album || np.Album || 'Unknown Album';
|
||||
const art = np.Art || np.art || {};
|
||||
const artStatus = art.ArtImageStatus || art.artImageStatus;
|
||||
const artUrl = artStatus === 'IMAGE_PRESENT' ? (art.URL || art.url || '') : '';
|
||||
|
||||
npContainer.innerHTML = `
|
||||
<img class="album-art" src="${artUrl}" alt="Artwork">
|
||||
<div class="now-playing-info">
|
||||
<strong>${track}</strong><br>
|
||||
${artist} - ${album}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
const vol = info.actualVolume || (info.volume && (typeof info.volume.actualvolume === 'number' ? info.volume.actualvolume : (typeof info.volume.actual === 'number' ? info.volume.actual : null)));
|
||||
const slider = document.getElementById(`vol-${deviceId}`);
|
||||
if (slider && typeof vol === 'number' && !adjusting[deviceId]) slider.value = String(vol);
|
||||
}
|
||||
} catch (err) {
|
||||
// console.warn('Bad WS message', err);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
// console.warn('WS error for', ip);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
// Try to reconnect after a delay
|
||||
setTimeout(() => {
|
||||
if (deviceSockets[key] === ws) {
|
||||
delete deviceSockets[key];
|
||||
}
|
||||
openDeviceWebSocket(deviceId);
|
||||
}, 3000);
|
||||
};
|
||||
} catch (e) {
|
||||
// console.warn('Failed to open WS for', deviceId, e);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDetails(deviceId) {
|
||||
const el = document.getElementById(`details-${deviceId}`);
|
||||
if (el) {
|
||||
el.classList.toggle('visible');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
fetchDevices();
|
||||
fetchVersion();
|
||||
setInterval(fetchDevices, 30000);
|
||||
});
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Stockholm Mini - Reverse Engineered</title>
|
||||
<link rel="stylesheet" href="../shared/common.css">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Stockholm Mini</h1>
|
||||
<p style="margin-top: -10px; font-style: italic; color: #666; font-size: 0.9rem;">A minimal reverse-engineered SoundTouch controller.</p>
|
||||
<p style="margin-bottom: 20px;"><a href="/" style="color: #00bcd4; text-decoration: none; font-size: 0.9rem;">← Back to selection</a></p>
|
||||
|
||||
<div id="device-list"></div>
|
||||
</div>
|
||||
|
||||
<footer style="margin-top: 50px;">
|
||||
<span id="version-info">AfterTouch</span>
|
||||
</footer>
|
||||
|
||||
<script src="../shared/common.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
@font-face {
|
||||
font-family: 'bose';
|
||||
src: url('bose.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #121212; color: #e0e0e0; margin: 0; padding: 20px; }
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
h1 { color: #fff; border-bottom: 1px solid #333; padding-bottom: 10px; }
|
||||
.device-card { background: #1e1e1e; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); }
|
||||
.device-info h2 { margin-top: 0; color: #00bcd4; margin-bottom: 0; }
|
||||
.device-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.device-title-row { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; }
|
||||
.device-title { margin: 0; font-size: 1.5rem; line-height: 1.2; }
|
||||
.device-subtitle { color: #888; font-size: 0.85rem; margin: 0; display: flex; align-items: center; }
|
||||
.info-toggle { background: none; color: #555; padding: 0; width: 1.15rem; height: 1.15rem; display: inline-flex; align-items: center; justify-content: center; border: 1px solid #444; border-radius: 50%; font-size: 0.7rem; font-style: italic; cursor: pointer; line-height: 1; transition: all 0.2s; flex-shrink: 0; }
|
||||
.info-toggle:hover { color: #aaa; border-color: #666; background: #2a2a2a; }
|
||||
.device-details { display: none; margin-top: 10px; font-size: 0.8rem; background: #252525; padding: 10px; border-radius: 4px; color: #aaa; border-left: 2px solid #00bcd4; }
|
||||
.device-details.visible { display: block; }
|
||||
.device-details p { margin: 4px 0; }
|
||||
.device-details code { color: #ccc; }
|
||||
.controls { display: flex; gap: 10px; margin-top: 20px; }
|
||||
button { background: #333; color: #fff; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; transition: background 0.2s; }
|
||||
button:hover { background: #444; }
|
||||
button.primary { background: #00bcd4; color: #000; font-weight: bold; }
|
||||
button.primary:hover { background: #00acc1; }
|
||||
.power-icon { font-family: bose, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 1.25rem; line-height: 1; height: 2.25rem; width: 2.25rem; padding: 0; display: inline-flex; align-items: center; justify-content: center; background: #2a2a2a; border-radius: 50%; color: #00bcd4; border: 1px solid #00bcd4; }
|
||||
.power-icon:hover { background: #3a3a3a; }
|
||||
.power-icon.off { color: #666; border-color: #444; background: #1a1a1a; }
|
||||
.power-icon.on { background: #00bcd4; color: #000; border-color: #00bcd4; }
|
||||
.power-icon.on:hover { background: #00acc1; }
|
||||
.status-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.8em; background: #333; margin-left: 10px; }
|
||||
.now-playing { margin-top: 20px; padding-top: 20px; border-top: 1px solid #333; display: flex; gap: 15px; align-items: center; min-height: 80px; }
|
||||
.now-playing-info { flex-grow: 1; }
|
||||
.album-art { width: 80px; height: 80px; border-radius: 4px; background: #2a2a2a; flex-shrink: 0; object-fit: cover; box-shadow: 0 2px 4px rgba(0,0,0,0.5); }
|
||||
.album-art[src=""] { display: none; }
|
||||
.volume-container { margin-top: 15px; display: flex; align-items: center; gap: 10px; }
|
||||
input[type=range] { flex-grow: 1; }
|
||||
#device-list:empty::after { content: "Searching for devices..."; color: #666; font-style: italic; }
|
||||
@@ -1,197 +0,0 @@
|
||||
// Package migration provides device directory migration functionality.
|
||||
// This package is designed to be easily removable in future releases once
|
||||
// all devices have been migrated from serial-based to MAC-based directory structures.
|
||||
//
|
||||
// TODO: Remove this package after 3-4 releases when most devices are migrated.
|
||||
package migration
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// Config holds migration configuration
|
||||
type Config struct {
|
||||
// Enabled controls whether migration is active
|
||||
Enabled bool
|
||||
// DryRun logs what would be migrated without actually doing it
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// Manager handles device directory migrations
|
||||
type Manager struct {
|
||||
datastore *datastore.DataStore
|
||||
config Config
|
||||
}
|
||||
|
||||
// NewManager creates a new migration manager
|
||||
func NewManager(ds *datastore.DataStore, config Config) *Manager {
|
||||
return &Manager{
|
||||
datastore: ds,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// MigrateDevicesIfNeeded checks discovered devices and migrates any that need it
|
||||
func (m *Manager) MigrateDevicesIfNeeded(existingDevices []models.ServiceDeviceInfo, targetDeviceID string) bool {
|
||||
if !m.config.Enabled {
|
||||
return false
|
||||
}
|
||||
|
||||
migrated := false
|
||||
|
||||
for i := range existingDevices {
|
||||
existing := &existingDevices[i]
|
||||
if existing.DeviceID != targetDeviceID {
|
||||
if m.config.DryRun {
|
||||
log.Printf("[MIGRATION DRY-RUN] Would migrate device directory: %s -> %s", existing.DeviceID, targetDeviceID)
|
||||
} else {
|
||||
log.Printf("[MIGRATION] Migrating device directory: %s -> %s", existing.DeviceID, targetDeviceID)
|
||||
|
||||
if m.migrateDeviceDirectory(existing.AccountID, existing.DeviceID, targetDeviceID) {
|
||||
migrated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return migrated
|
||||
}
|
||||
|
||||
// migrateDeviceDirectory renames device directory from old ID to new ID
|
||||
func (m *Manager) migrateDeviceDirectory(accountID, oldDeviceID, newDeviceID string) bool {
|
||||
// Use direct paths for migration - don't resolve through mappings
|
||||
// because mappings might point new ID back to old directory during migration
|
||||
accountDevicesDir := m.datastore.AccountDevicesDir(accountID)
|
||||
oldDir := filepath.Join(accountDevicesDir, oldDeviceID)
|
||||
newDir := filepath.Join(accountDevicesDir, newDeviceID)
|
||||
|
||||
// Log directory contents before migration
|
||||
m.logDirectoryContents("Source directory", oldDir)
|
||||
|
||||
// Check if old directory exists
|
||||
if _, err := os.Stat(oldDir); os.IsNotExist(err) {
|
||||
log.Printf("[MIGRATION] Source directory %s does not exist, nothing to migrate", oldDir)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if new directory already exists
|
||||
if _, err := os.Stat(newDir); err == nil {
|
||||
log.Printf("[MIGRATION] Target directory %s already exists, removing it first", newDir)
|
||||
|
||||
if removeErr := os.RemoveAll(newDir); removeErr != nil {
|
||||
log.Printf("[MIGRATION ERROR] Failed to remove existing target directory: %v", removeErr)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
parentDir := filepath.Dir(newDir)
|
||||
if err := os.MkdirAll(parentDir, 0755); err != nil {
|
||||
log.Printf("[MIGRATION ERROR] Failed to create parent directory %s: %v", parentDir, err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Rename the entire directory
|
||||
if err := os.Rename(oldDir, newDir); err != nil {
|
||||
log.Printf("[MIGRATION ERROR] Failed to rename directory from %s to %s: %v", oldDir, newDir, err)
|
||||
return false
|
||||
}
|
||||
|
||||
log.Printf("[MIGRATION SUCCESS] Migrated device directory: %s -> %s", oldDeviceID, newDeviceID)
|
||||
m.logDirectoryContents("Migrated directory", newDir)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// logDirectoryContents logs the contents of a directory for debugging
|
||||
func (m *Manager) logDirectoryContents(label, dirPath string) {
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
log.Printf("[MIGRATION] %s (%s): Error reading - %v", label, dirPath, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[MIGRATION] %s (%s): %d files", label, dirPath, len(entries))
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
info, err := entry.Info()
|
||||
if err == nil {
|
||||
log.Printf("[MIGRATION] - %s (%d bytes)", entry.Name(), info.Size())
|
||||
} else {
|
||||
log.Printf("[MIGRATION] - %s (size unknown)", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetStats returns migration statistics
|
||||
func (m *Manager) GetStats() Stats {
|
||||
// This could be extended to track migration metrics
|
||||
return Stats{
|
||||
Enabled: m.config.Enabled,
|
||||
DryRun: m.config.DryRun,
|
||||
}
|
||||
}
|
||||
|
||||
// Stats holds migration statistics
|
||||
type Stats struct {
|
||||
Enabled bool
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// IsLegacyDeviceID checks if a device ID appears to be legacy (non-MAC format)
|
||||
func IsLegacyDeviceID(deviceID string) bool {
|
||||
// Serial numbers typically start with I or K and are long
|
||||
if len(deviceID) > 15 && (deviceID[0] == 'I' || deviceID[0] == 'K') {
|
||||
return true
|
||||
}
|
||||
|
||||
// IP addresses
|
||||
if isIPAddress(deviceID) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Assume MAC addresses are 12 hex characters
|
||||
if len(deviceID) == 12 && isHexString(deviceID) {
|
||||
return false // This is likely a MAC address
|
||||
}
|
||||
|
||||
// Other formats are considered legacy
|
||||
return true
|
||||
}
|
||||
|
||||
// isIPAddress checks if a string looks like an IP address
|
||||
func isIPAddress(s string) bool {
|
||||
if len(s) < 7 || len(s) > 15 {
|
||||
return false
|
||||
}
|
||||
|
||||
dotCount := 0
|
||||
|
||||
for _, c := range s {
|
||||
if c == '.' {
|
||||
dotCount++
|
||||
} else if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return dotCount == 3
|
||||
}
|
||||
|
||||
// isHexString checks if a string contains only hexadecimal characters
|
||||
func isHexString(s string) bool {
|
||||
for _, c := range s {
|
||||
if (c < '0' || c > '9') && (c < 'A' || c > 'F') && (c < 'a' || c > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,460 +0,0 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func TestMigration_FilePreservation(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "migration-file-preservation-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
manager := NewManager(ds, Config{Enabled: true, DryRun: false})
|
||||
|
||||
accountID := "test-account"
|
||||
oldDeviceID := "I6332527703739342000020" // Legacy serial number
|
||||
newDeviceID := "A81B6A536A98" // MAC address
|
||||
|
||||
// 1. Create old device directory with multiple files
|
||||
oldDir := ds.AccountDeviceDir(accountID, oldDeviceID)
|
||||
if err := os.MkdirAll(oldDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create DeviceInfo.xml with serial number as deviceID (legacy format)
|
||||
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="I6332527703739342000020">
|
||||
<name>Sound Speaker Legacy</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<moduleType>sm2</moduleType>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>3.4.6.2356</softwareVersion>
|
||||
<serialNumber>I6332527703739342000020</serialNumber>
|
||||
</component>
|
||||
<component>
|
||||
<componentCategory>PackagedProduct</componentCategory>
|
||||
<serialNumber>069231P63364828AE</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>A81B6A536A98</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
</info>`
|
||||
|
||||
if err := os.WriteFile(filepath.Join(oldDir, "DeviceInfo.xml"), []byte(deviceInfoXML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create Presets.xml
|
||||
presetsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets>
|
||||
<preset id="1">
|
||||
<ContentItem source="SPOTIFY" location="spotify://track/123">
|
||||
<itemName>Test Song</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
|
||||
if err := os.WriteFile(filepath.Join(oldDir, "Presets.xml"), []byte(presetsXML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create Sources.xml
|
||||
sourcesXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sources>
|
||||
<source id="123" displayName="SPOTIFY" secret="token123" secretType="Audio">
|
||||
<accountDisplayName>user@example.com</accountDisplayName>
|
||||
</source>
|
||||
</sources>`
|
||||
|
||||
if err := os.WriteFile(filepath.Join(oldDir, "Sources.xml"), []byte(sourcesXML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create Recents.xml
|
||||
recentsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<recents>
|
||||
<recent>
|
||||
<ContentItem source="TUNEIN" location="tunein://station/s12345">
|
||||
<itemName>BBC Radio 1</itemName>
|
||||
</ContentItem>
|
||||
</recent>
|
||||
</recents>`
|
||||
|
||||
if err := os.WriteFile(filepath.Join(oldDir, "Recents.xml"), []byte(recentsXML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 2. Verify old directory has all files
|
||||
oldEntries, err := os.ReadDir(oldDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(oldEntries) != 4 {
|
||||
t.Fatalf("Expected 4 files in old directory, got %d", len(oldEntries))
|
||||
}
|
||||
|
||||
t.Logf("Before migration - Old directory (%s) contains %d files:", oldDeviceID, len(oldEntries))
|
||||
for _, entry := range oldEntries {
|
||||
t.Logf(" - %s", entry.Name())
|
||||
}
|
||||
|
||||
// 3. Create device info for migration
|
||||
existingDevice := models.ServiceDeviceInfo{
|
||||
DeviceID: oldDeviceID,
|
||||
AccountID: accountID,
|
||||
Name: "Sound Speaker Legacy",
|
||||
IPAddress: "192.168.1.100",
|
||||
}
|
||||
|
||||
// 4. Perform migration
|
||||
migrated := manager.MigrateDevicesIfNeeded([]models.ServiceDeviceInfo{existingDevice}, newDeviceID)
|
||||
if !migrated {
|
||||
t.Fatal("Migration should have occurred")
|
||||
}
|
||||
|
||||
// 5. Verify old directory no longer exists
|
||||
if _, err := os.Stat(oldDir); !os.IsNotExist(err) {
|
||||
t.Error("Old directory should not exist after migration")
|
||||
}
|
||||
|
||||
// 6. Verify new directory exists with all files preserved
|
||||
newDir := ds.AccountDeviceDir(accountID, newDeviceID)
|
||||
newEntries, err := os.ReadDir(newDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(newEntries) != 4 {
|
||||
t.Fatalf("Expected 4 files in new directory after migration, got %d", len(newEntries))
|
||||
}
|
||||
|
||||
t.Logf("After migration - New directory (%s) contains %d files:", newDeviceID, len(newEntries))
|
||||
for _, entry := range newEntries {
|
||||
t.Logf(" - %s", entry.Name())
|
||||
}
|
||||
|
||||
// 7. Verify each file exists and has content
|
||||
expectedFiles := []string{"DeviceInfo.xml", "Presets.xml", "Sources.xml", "Recents.xml"}
|
||||
for _, filename := range expectedFiles {
|
||||
filePath := filepath.Join(newDir, filename)
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
t.Errorf("File %s should exist after migration: %v", filename, err)
|
||||
continue
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Errorf("File %s should not be empty after migration", filename)
|
||||
}
|
||||
t.Logf(" ✓ %s preserved (%d bytes)", filename, len(data))
|
||||
}
|
||||
|
||||
// 8. Verify specific content preservation
|
||||
// Presets should still contain the test song
|
||||
presetsData, _ := os.ReadFile(filepath.Join(newDir, "Presets.xml"))
|
||||
if !containsString(string(presetsData), "Test Song") {
|
||||
t.Error("Presets.xml should preserve original content")
|
||||
}
|
||||
|
||||
// Sources should still contain the Spotify account
|
||||
sourcesData, _ := os.ReadFile(filepath.Join(newDir, "Sources.xml"))
|
||||
if !containsString(string(sourcesData), "user@example.com") {
|
||||
t.Error("Sources.xml should preserve original content")
|
||||
}
|
||||
|
||||
// Recents should still contain the radio station
|
||||
recentsData, _ := os.ReadFile(filepath.Join(newDir, "Recents.xml"))
|
||||
if !containsString(string(recentsData), "BBC Radio 1") {
|
||||
t.Error("Recents.xml should preserve original content")
|
||||
}
|
||||
|
||||
t.Log("✅ Migration successfully preserved all files with their original content")
|
||||
}
|
||||
|
||||
func TestMigration_DryRun(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "migration-dry-run-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
manager := NewManager(ds, Config{Enabled: true, DryRun: true}) // DRY RUN MODE
|
||||
|
||||
accountID := "test-account"
|
||||
oldDeviceID := "I6332527703739342000020"
|
||||
newDeviceID := "A81B6A536A98"
|
||||
|
||||
// Create old directory with files
|
||||
oldDir := ds.AccountDeviceDir(accountID, oldDeviceID)
|
||||
if err := os.MkdirAll(oldDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
deviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="I6332527703739342000020">
|
||||
<name>Test Device</name>
|
||||
</info>`
|
||||
|
||||
if err := os.WriteFile(filepath.Join(oldDir, "DeviceInfo.xml"), []byte(deviceInfoXML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create device info for migration
|
||||
existingDevice := models.ServiceDeviceInfo{
|
||||
DeviceID: oldDeviceID,
|
||||
AccountID: accountID,
|
||||
}
|
||||
|
||||
// Perform dry-run migration
|
||||
migrated := manager.MigrateDevicesIfNeeded([]models.ServiceDeviceInfo{existingDevice}, newDeviceID)
|
||||
if migrated {
|
||||
t.Error("Dry run should not report actual migration")
|
||||
}
|
||||
|
||||
// Verify old directory still exists (no actual migration)
|
||||
if _, err := os.Stat(oldDir); os.IsNotExist(err) {
|
||||
t.Error("Old directory should still exist after dry run")
|
||||
}
|
||||
|
||||
// Verify new directory does not exist
|
||||
newDir := ds.AccountDeviceDir(accountID, newDeviceID)
|
||||
if _, err := os.Stat(newDir); !os.IsNotExist(err) {
|
||||
t.Error("New directory should not exist after dry run")
|
||||
}
|
||||
|
||||
t.Log("✅ Dry run mode correctly simulated migration without making changes")
|
||||
}
|
||||
|
||||
func TestMigration_Disabled(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "migration-disabled-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
manager := NewManager(ds, Config{Enabled: false, DryRun: false}) // MIGRATION DISABLED
|
||||
|
||||
accountID := "test-account"
|
||||
oldDeviceID := "I6332527703739342000020"
|
||||
newDeviceID := "A81B6A536A98"
|
||||
|
||||
// Create device info for migration
|
||||
existingDevice := models.ServiceDeviceInfo{
|
||||
DeviceID: oldDeviceID,
|
||||
AccountID: accountID,
|
||||
}
|
||||
|
||||
// Attempt migration
|
||||
migrated := manager.MigrateDevicesIfNeeded([]models.ServiceDeviceInfo{existingDevice}, newDeviceID)
|
||||
if migrated {
|
||||
t.Error("Migration should not occur when disabled")
|
||||
}
|
||||
|
||||
t.Log("✅ Migration correctly disabled")
|
||||
}
|
||||
|
||||
// Helper function to check if a string contains a substring
|
||||
func containsString(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || containsSubstring(s, substr))
|
||||
}
|
||||
|
||||
func containsSubstring(s, substr string) bool {
|
||||
if len(substr) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(s) < len(substr) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestMigration_CompleteFlowWithDeviceInfoUpdate(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "migration-complete-flow-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
manager := NewManager(ds, Config{Enabled: true, DryRun: false})
|
||||
|
||||
accountID := "test-account"
|
||||
oldDeviceID := "I6332527703739342000020" // Legacy serial number
|
||||
newDeviceID := "A81B6A536A98" // MAC address
|
||||
|
||||
// 1. Create old device directory with legacy DeviceInfo.xml (deviceID=serial)
|
||||
oldDir := ds.AccountDeviceDir(accountID, oldDeviceID)
|
||||
if err := os.MkdirAll(oldDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
legacyDeviceInfoXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="I6332527703739342000020">
|
||||
<name>Sound Speaker Legacy</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<moduleType>sm2</moduleType>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>3.4.6.2356</softwareVersion>
|
||||
<serialNumber>I6332527703739342000020</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>A81B6A536A98</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
</info>`
|
||||
|
||||
if err := os.WriteFile(filepath.Join(oldDir, "DeviceInfo.xml"), []byte(legacyDeviceInfoXML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create Presets.xml to verify preservation
|
||||
presetsXML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets>
|
||||
<preset id="1">
|
||||
<ContentItem source="SPOTIFY" location="spotify://track/123">
|
||||
<itemName>My Favorite Song</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
</presets>`
|
||||
|
||||
if err := os.WriteFile(filepath.Join(oldDir, "Presets.xml"), []byte(presetsXML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Log("Step 1: Legacy directory created with deviceID=serial in DeviceInfo.xml")
|
||||
|
||||
// 2. Perform migration
|
||||
existingDevice := models.ServiceDeviceInfo{
|
||||
DeviceID: oldDeviceID,
|
||||
AccountID: accountID,
|
||||
Name: "Sound Speaker Legacy",
|
||||
IPAddress: "192.168.1.100",
|
||||
}
|
||||
|
||||
migrated := manager.MigrateDevicesIfNeeded([]models.ServiceDeviceInfo{existingDevice}, newDeviceID)
|
||||
if !migrated {
|
||||
t.Fatal("Migration should have occurred")
|
||||
}
|
||||
|
||||
t.Log("Step 2: Migration completed - directory renamed, all files preserved")
|
||||
|
||||
// 3. Verify migration moved files but preserved content
|
||||
newDir := ds.AccountDeviceDir(accountID, newDeviceID)
|
||||
|
||||
// Check that Presets.xml was preserved
|
||||
preservedPresetsData, err := os.ReadFile(filepath.Join(newDir, "Presets.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("Presets.xml should be preserved after migration: %v", err)
|
||||
}
|
||||
if !containsString(string(preservedPresetsData), "My Favorite Song") {
|
||||
t.Error("Presets.xml content should be preserved")
|
||||
}
|
||||
|
||||
t.Log("Step 3: Verified Presets.xml preserved during migration")
|
||||
|
||||
// 4. Simulate SaveDeviceInfo with fresh /info data (like real discovery)
|
||||
// This should overwrite DeviceInfo.xml with correct MAC-based deviceID
|
||||
freshDeviceInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: newDeviceID, // MAC address as deviceID
|
||||
AccountID: accountID,
|
||||
Name: "Sound Machinechen", // Fresh name from /info
|
||||
IPAddress: "192.168.1.100", // Fresh IP
|
||||
MacAddress: newDeviceID,
|
||||
DeviceSerialNumber: oldDeviceID, // Serial goes in component
|
||||
ProductCode: "SoundTouch 10 sm2", // Fresh product info
|
||||
FirmwareVersion: "27.0.6.46330.5043500",
|
||||
ProductSerialNumber: "069231P63364828AE",
|
||||
DiscoveryMethod: "Test Discovery",
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(accountID, newDeviceID, freshDeviceInfo); err != nil {
|
||||
t.Fatalf("Failed to save fresh device info: %v", err)
|
||||
}
|
||||
|
||||
t.Log("Step 4: SaveDeviceInfo called with fresh /info data")
|
||||
|
||||
// 5. Verify DeviceInfo.xml now has correct MAC-based deviceID
|
||||
updatedDeviceInfoData, err := os.ReadFile(filepath.Join(newDir, "DeviceInfo.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("DeviceInfo.xml should exist after SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
updatedXML := string(updatedDeviceInfoData)
|
||||
|
||||
// Should contain deviceID="A81B6A536A98" (MAC address)
|
||||
if !containsString(updatedXML, `deviceID="A81B6A536A98"`) {
|
||||
t.Errorf("DeviceInfo.xml should have deviceID set to MAC address, content:\n%s", updatedXML)
|
||||
}
|
||||
|
||||
// Should contain fresh device name from /info
|
||||
if !containsString(updatedXML, "Sound Machinechen") {
|
||||
t.Errorf("DeviceInfo.xml should have fresh device name from /info")
|
||||
}
|
||||
|
||||
// Should contain serial number in component (not as deviceID)
|
||||
if !containsString(updatedXML, "I6332527703739342000020") {
|
||||
t.Errorf("DeviceInfo.xml should still contain serial number in component")
|
||||
}
|
||||
|
||||
// Should contain product serial in component
|
||||
if !containsString(updatedXML, "069231P63364828AE") {
|
||||
t.Errorf("DeviceInfo.xml should contain product serial in component")
|
||||
}
|
||||
|
||||
t.Log("Step 5: Verified DeviceInfo.xml has correct MAC-based deviceID attribute")
|
||||
|
||||
// 6. Verify Presets.xml still exists and wasn't overwritten
|
||||
finalPresetsData, err := os.ReadFile(filepath.Join(newDir, "Presets.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("Presets.xml should still exist after SaveDeviceInfo: %v", err)
|
||||
}
|
||||
if !containsString(string(finalPresetsData), "My Favorite Song") {
|
||||
t.Error("Presets.xml should not be overwritten by SaveDeviceInfo")
|
||||
}
|
||||
|
||||
t.Log("Step 6: Verified Presets.xml was not overwritten by SaveDeviceInfo")
|
||||
|
||||
// 7. Verify data is accessible via MAC address
|
||||
retrievedInfo, err := ds.GetDeviceInfo(accountID, newDeviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("Should be able to retrieve device info by MAC address: %v", err)
|
||||
}
|
||||
|
||||
if retrievedInfo.DeviceID != newDeviceID {
|
||||
t.Errorf("Retrieved device info should have MAC-based deviceID, got %s", retrievedInfo.DeviceID)
|
||||
}
|
||||
|
||||
if retrievedInfo.Name != "Sound Machinechen" {
|
||||
t.Errorf("Retrieved device info should have fresh name, got %s", retrievedInfo.Name)
|
||||
}
|
||||
|
||||
t.Log("Step 7: Verified device info retrieval works with MAC address")
|
||||
|
||||
t.Log("✅ Complete migration flow verified:")
|
||||
t.Log(" 1. Migration preserves all files (Presets.xml, Sources.xml, etc.)")
|
||||
t.Log(" 2. SaveDeviceInfo updates DeviceInfo.xml with correct deviceID=MAC")
|
||||
t.Log(" 3. Serial number preserved in component, not as deviceID")
|
||||
t.Log(" 4. Fresh /info data properly integrated")
|
||||
t.Log(" 5. User data (presets, etc.) completely preserved")
|
||||
}
|
||||
@@ -215,7 +215,7 @@ func (r *Recorder) getRecordingDir(category string, sanitizedSegments []string)
|
||||
}
|
||||
|
||||
func (r *Recorder) getRecordingPath(dir, method string) string {
|
||||
timestamp := time.Now().Format("20060102-150405.000")
|
||||
timestamp := time.Now().Format("15-04-05.000")
|
||||
count := atomic.AddUint64(&r.counter, 1)
|
||||
filename := fmt.Sprintf("%04d-%s-%s.http", count, timestamp, method)
|
||||
|
||||
@@ -441,44 +441,20 @@ func (r *Recorder) parseInteractionFile(rel, path string, parts []string) (Inter
|
||||
filename := parts[len(parts)-1]
|
||||
fnParts := strings.Split(strings.TrimSuffix(filename, ".http"), "-")
|
||||
|
||||
timestamp := ""
|
||||
method, counter := "UNKNOWN", 0
|
||||
|
||||
if len(fnParts) >= 1 {
|
||||
_, _ = fmt.Sscanf(fnParts[0], "%d", &counter)
|
||||
date := ""
|
||||
if len(sessionID) >= 8 {
|
||||
date = sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8]
|
||||
}
|
||||
|
||||
// Check if this is the new format: count-yyyyMMdd-HHMMSS.sss-method.http
|
||||
// New format has 4 parts and the second part is 8 digits (yyyyMMdd)
|
||||
if len(fnParts) == 4 && len(fnParts[1]) == 8 {
|
||||
dateStr := fnParts[1] // yyyyMMdd
|
||||
timeStr := fnParts[2] // HHMMSS.sss
|
||||
method = fnParts[3]
|
||||
|
||||
// Format date: yyyyMMdd -> yyyy-MM-dd
|
||||
date := dateStr[0:4] + "-" + dateStr[4:6] + "-" + dateStr[6:8]
|
||||
|
||||
// Format time: HHMMSS.sss -> HH:MM:SS.sss
|
||||
if len(timeStr) >= 6 {
|
||||
time := timeStr[0:2] + ":" + timeStr[2:4] + ":" + timeStr[4:]
|
||||
timestamp = date + " " + time
|
||||
}
|
||||
} else if len(fnParts) >= 5 {
|
||||
// Legacy format: count-HH-MM-SS.sss-method.http
|
||||
// Extract date from sessionID for backward compatibility
|
||||
date := ""
|
||||
if len(sessionID) >= 8 {
|
||||
date = sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8]
|
||||
}
|
||||
timestamp := ""
|
||||
|
||||
if len(fnParts) >= 4 {
|
||||
timeStr := fnParts[1] + ":" + fnParts[2] + ":" + fnParts[3]
|
||||
timestamp = timeStr
|
||||
|
||||
if date != "" {
|
||||
timestamp = date + " " + timeStr
|
||||
}
|
||||
|
||||
method = fnParts[4]
|
||||
}
|
||||
|
||||
requestPath := "/" + strings.Join(parts[2:len(parts)-1], "/")
|
||||
@@ -486,6 +462,15 @@ func (r *Recorder) parseInteractionFile(rel, path string, parts []string) (Inter
|
||||
requestPath = "/"
|
||||
}
|
||||
|
||||
method, counter := "UNKNOWN", 0
|
||||
if len(fnParts) >= 1 {
|
||||
_, _ = fmt.Sscanf(fnParts[0], "%d", &counter)
|
||||
}
|
||||
|
||||
if len(fnParts) >= 5 {
|
||||
method = fnParts[4]
|
||||
}
|
||||
|
||||
return Interaction{
|
||||
ID: filename,
|
||||
Session: sessionID,
|
||||
@@ -500,32 +485,18 @@ func (r *Recorder) parseInteractionFile(rel, path string, parts []string) (Inter
|
||||
}
|
||||
|
||||
func (r *Recorder) getFullTimestamp(sessionID, filename string) string {
|
||||
fnParts := strings.Split(strings.TrimSuffix(filename, ".http"), "-")
|
||||
|
||||
// Check if this is the new format: count-yyyyMMdd-HHMMSS.sss-method.http
|
||||
// New format has 4 parts and the second part is 8 digits (yyyyMMdd)
|
||||
if len(fnParts) == 4 && len(fnParts[1]) == 8 {
|
||||
dateStr := fnParts[1] // yyyyMMdd
|
||||
timeStr := fnParts[2] // HHMMSS.sss
|
||||
|
||||
if len(timeStr) >= 6 {
|
||||
date := dateStr[0:4] + "-" + dateStr[4:6] + "-" + dateStr[6:8]
|
||||
time := timeStr[0:2] + "-" + timeStr[2:4] + "-" + timeStr[4:]
|
||||
|
||||
return date + "-" + time
|
||||
}
|
||||
} else if len(fnParts) >= 5 {
|
||||
// Legacy format: count-HH-MM-SS.sss-method.http
|
||||
if len(sessionID) < 8 {
|
||||
return ""
|
||||
}
|
||||
|
||||
date := sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8]
|
||||
|
||||
return date + "-" + fnParts[1] + "-" + fnParts[2] + "-" + fnParts[3]
|
||||
if len(sessionID) < 8 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ""
|
||||
date := sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8]
|
||||
fnParts := strings.Split(strings.TrimSuffix(filename, ".http"), "-")
|
||||
|
||||
if len(fnParts) < 4 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return date + "-" + fnParts[1] + "-" + fnParts[2] + "-" + fnParts[3]
|
||||
}
|
||||
|
||||
func (r *Recorder) peekStatus(path string) int {
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestRecorder_Record_Structure(t *testing.T) {
|
||||
{
|
||||
name: "path_with_ip",
|
||||
category: "self",
|
||||
path: "/setup/info/192.168.1.100",
|
||||
path: "/setup/info/192.168.178.35",
|
||||
expected: "setup/info/{ip}",
|
||||
},
|
||||
{
|
||||
@@ -131,7 +131,7 @@ func TestRecorder_Record_Sanitization(t *testing.T) {
|
||||
req := &http.Request{
|
||||
Method: "GET",
|
||||
URL: &url.URL{
|
||||
Path: "/info/192.168.1.100/A81B6A536A98",
|
||||
Path: "/info/192.168.178.35/A81B6A536A98",
|
||||
},
|
||||
Header: make(http.Header),
|
||||
}
|
||||
@@ -331,7 +331,7 @@ func TestRecorder_EnvFile(t *testing.T) {
|
||||
req := &http.Request{
|
||||
Method: "GET",
|
||||
URL: &url.URL{
|
||||
Path: "/info/192.168.1.100",
|
||||
Path: "/info/192.168.178.35",
|
||||
},
|
||||
Header: make(http.Header),
|
||||
}
|
||||
@@ -352,8 +352,8 @@ func TestRecorder_EnvFile(t *testing.T) {
|
||||
t.Fatalf("Failed to unmarshal env file: %v", err)
|
||||
}
|
||||
|
||||
if content["session"]["ip"] != "192.168.1.100" {
|
||||
t.Errorf("Expected ip to be 192.168.1.100, got %s", content["session"]["ip"])
|
||||
if content["session"]["ip"] != "192.168.178.35" {
|
||||
t.Errorf("Expected ip to be 192.168.178.35, got %s", content["session"]["ip"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -847,99 +847,3 @@ func TestRecorder_ListInteractions_FullTimestamp(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecorder_NewFilenameFormat_WithDate(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "recorder-new-format-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
r := NewRecorder(tmpDir)
|
||||
sessionID := "20260223-150000-12345"
|
||||
r.SessionID = sessionID
|
||||
|
||||
// Create recordings with the new format that includes date in filename
|
||||
basePath := filepath.Join(tmpDir, "interactions", sessionID, "self", "test")
|
||||
os.MkdirAll(basePath, 0755)
|
||||
|
||||
files := []string{
|
||||
"0047-20260223-215306.128-GET.http",
|
||||
"0048-20260223-215306.417-POST.http",
|
||||
"0049-20260223-080034.500-GET.http",
|
||||
"0050-20260223-080034.507-PUT.http",
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
os.WriteFile(filepath.Join(basePath, f), []byte("test content"), 0644)
|
||||
}
|
||||
|
||||
t.Run("Parse_New_Format_Timestamps", func(t *testing.T) {
|
||||
interactions, err := r.ListInteractions(sessionID, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListInteractions failed: %v", err)
|
||||
}
|
||||
|
||||
if len(interactions) != 4 {
|
||||
t.Fatalf("Expected 4 interactions, got %d", len(interactions))
|
||||
}
|
||||
|
||||
// Check that timestamps include both date and time from filename
|
||||
expectedTimestamps := []string{
|
||||
"2026-02-23 21:53:06.128",
|
||||
"2026-02-23 21:53:06.417",
|
||||
"2026-02-23 08:00:34.500",
|
||||
"2026-02-23 08:00:34.507",
|
||||
}
|
||||
|
||||
for i, interaction := range interactions {
|
||||
if interaction.Timestamp != expectedTimestamps[i] {
|
||||
t.Errorf("Expected timestamp %s, got %s for interaction %d",
|
||||
expectedTimestamps[i], interaction.Timestamp, i)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Parse_Methods_From_New_Format", func(t *testing.T) {
|
||||
interactions, err := r.ListInteractions(sessionID, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListInteractions failed: %v", err)
|
||||
}
|
||||
|
||||
expectedMethods := []string{"GET", "POST", "GET", "PUT"}
|
||||
for i, interaction := range interactions {
|
||||
if interaction.Method != expectedMethods[i] {
|
||||
t.Errorf("Expected method %s, got %s for interaction %d",
|
||||
expectedMethods[i], interaction.Method, i)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Filter_By_New_Format_Timestamp", func(t *testing.T) {
|
||||
// Filter for interactions after 10:00:00 on that day
|
||||
interactions, err := r.ListInteractions(sessionID, "", "2026-02-23 10:00:00")
|
||||
if err != nil {
|
||||
t.Fatalf("ListInteractions failed: %v", err)
|
||||
}
|
||||
|
||||
// Should get the two evening interactions (21:53:06.xxx)
|
||||
if len(interactions) != 2 {
|
||||
t.Fatalf("Expected 2 interactions after 10:00:00, got %d", len(interactions))
|
||||
}
|
||||
|
||||
for _, interaction := range interactions {
|
||||
if !strings.Contains(interaction.Timestamp, "21:53:06") {
|
||||
t.Errorf("Expected evening timestamp, got %s", interaction.Timestamp)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetFullTimestamp_New_Format", func(t *testing.T) {
|
||||
// Test the getFullTimestamp function directly
|
||||
fullTS := r.getFullTimestamp(sessionID, "0047-20260223-215306.128-GET.http")
|
||||
expected := "2026-02-23-21-53-06.128"
|
||||
if fullTS != expected {
|
||||
t.Errorf("Expected full timestamp %s, got %s", expected, fullTS)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeviceInfoXML_RealWorldParsing(t *testing.T) {
|
||||
// Real XML response from a SoundTouch device's /info endpoint
|
||||
xmlData := `<info deviceID="A81B6A536A98">
|
||||
<name>Sound Machinechen</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<margeAccountUUID>3230304</margeAccountUUID>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</softwareVersion>
|
||||
<serialNumber>I6332527703739342000020</serialNumber>
|
||||
</component>
|
||||
<component>
|
||||
<componentCategory>PackagedProduct</componentCategory>
|
||||
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</softwareVersion>
|
||||
<serialNumber>069231P63364828AE</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<margeURL>https://streaming.bose.com</margeURL>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>A81B6A536A98</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
<networkInfo type="SMSC">
|
||||
<macAddress>A81B6A849D99</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
<moduleType>sm2</moduleType>
|
||||
<variant>rhino</variant>
|
||||
<variantMode>normal</variantMode>
|
||||
<countryCode>GB</countryCode>
|
||||
<regionCode>GB</regionCode>
|
||||
</info>`
|
||||
|
||||
manager := NewManager("http://localhost:8000", nil, nil)
|
||||
|
||||
// Parse the XML directly (simulating what GetLiveDeviceInfo does)
|
||||
var infoXML DeviceInfoXML
|
||||
if err := manager.parseDeviceInfoXML(strings.NewReader(xmlData), &infoXML); err != nil {
|
||||
t.Fatalf("Failed to parse XML: %v", err)
|
||||
}
|
||||
|
||||
// Verify basic fields
|
||||
if infoXML.DeviceID != "A81B6A536A98" {
|
||||
t.Errorf("Expected deviceID 'A81B6A536A98', got '%s'", infoXML.DeviceID)
|
||||
}
|
||||
|
||||
if infoXML.Name != "Sound Machinechen" {
|
||||
t.Errorf("Expected name 'Sound Machinechen', got '%s'", infoXML.Name)
|
||||
}
|
||||
|
||||
if infoXML.Type != "SoundTouch 10" {
|
||||
t.Errorf("Expected type 'SoundTouch 10', got '%s'", infoXML.Type)
|
||||
}
|
||||
|
||||
if infoXML.ModuleType != "sm2" {
|
||||
t.Errorf("Expected moduleType 'sm2', got '%s'", infoXML.ModuleType)
|
||||
}
|
||||
|
||||
if infoXML.MargeAccountUUID != "3230304" {
|
||||
t.Errorf("Expected margeAccountUUID '3230304', got '%s'", infoXML.MargeAccountUUID)
|
||||
}
|
||||
|
||||
if infoXML.MargeURL != "https://streaming.bose.com" {
|
||||
t.Errorf("Expected margeURL 'https://streaming.bose.com', got '%s'", infoXML.MargeURL)
|
||||
}
|
||||
|
||||
if infoXML.CountryCode != "GB" {
|
||||
t.Errorf("Expected countryCode 'GB', got '%s'", infoXML.CountryCode)
|
||||
}
|
||||
|
||||
if infoXML.RegionCode != "GB" {
|
||||
t.Errorf("Expected regionCode 'GB', got '%s'", infoXML.RegionCode)
|
||||
}
|
||||
|
||||
if infoXML.Variant != "rhino" {
|
||||
t.Errorf("Expected variant 'rhino', got '%s'", infoXML.Variant)
|
||||
}
|
||||
|
||||
if infoXML.VariantMode != "normal" {
|
||||
t.Errorf("Expected variantMode 'normal', got '%s'", infoXML.VariantMode)
|
||||
}
|
||||
|
||||
// Verify components
|
||||
if len(infoXML.Components) != 2 {
|
||||
t.Fatalf("Expected 2 components, got %d", len(infoXML.Components))
|
||||
}
|
||||
|
||||
scmFound := false
|
||||
packagedProductFound := false
|
||||
for _, comp := range infoXML.Components {
|
||||
switch comp.Category {
|
||||
case "SCM":
|
||||
scmFound = true
|
||||
expectedSoftware := "27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29"
|
||||
if comp.SoftwareVersion != expectedSoftware {
|
||||
t.Errorf("Expected SCM software version '%s', got '%s'", expectedSoftware, comp.SoftwareVersion)
|
||||
}
|
||||
if comp.SerialNumber != "I6332527703739342000020" {
|
||||
t.Errorf("Expected SCM serial 'I6332527703739342000020', got '%s'", comp.SerialNumber)
|
||||
}
|
||||
case "PackagedProduct":
|
||||
packagedProductFound = true
|
||||
if comp.SerialNumber != "069231P63364828AE" {
|
||||
t.Errorf("Expected PackagedProduct serial '069231P63364828AE', got '%s'", comp.SerialNumber)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !scmFound {
|
||||
t.Error("SCM component not found")
|
||||
}
|
||||
if !packagedProductFound {
|
||||
t.Error("PackagedProduct component not found")
|
||||
}
|
||||
|
||||
// Verify network info
|
||||
if len(infoXML.NetworkInfo) != 2 {
|
||||
t.Fatalf("Expected 2 networkInfo entries, got %d", len(infoXML.NetworkInfo))
|
||||
}
|
||||
|
||||
scmNetworkFound := false
|
||||
smscNetworkFound := false
|
||||
for _, net := range infoXML.NetworkInfo {
|
||||
switch net.Type {
|
||||
case "SCM":
|
||||
scmNetworkFound = true
|
||||
if net.MacAddress != "A81B6A536A98" {
|
||||
t.Errorf("Expected SCM MAC 'A81B6A536A98', got '%s'", net.MacAddress)
|
||||
}
|
||||
if net.IPAddress != "192.168.1.100" {
|
||||
t.Errorf("Expected SCM IP '192.168.1.100', got '%s'", net.IPAddress)
|
||||
}
|
||||
case "SMSC":
|
||||
smscNetworkFound = true
|
||||
if net.MacAddress != "A81B6A849D99" {
|
||||
t.Errorf("Expected SMSC MAC 'A81B6A849D99', got '%s'", net.MacAddress)
|
||||
}
|
||||
if net.IPAddress != "192.168.1.100" {
|
||||
t.Errorf("Expected SMSC IP '192.168.1.100', got '%s'", net.IPAddress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !scmNetworkFound {
|
||||
t.Error("SCM networkInfo not found")
|
||||
}
|
||||
if !smscNetworkFound {
|
||||
t.Error("SMSC networkInfo not found")
|
||||
}
|
||||
|
||||
// Test the GetPrimaryMacAddress method
|
||||
primaryMAC := infoXML.GetPrimaryMacAddress()
|
||||
if primaryMAC != "A81B6A536A98" {
|
||||
t.Errorf("Expected primary MAC 'A81B6A536A98', got '%s'", primaryMAC)
|
||||
}
|
||||
|
||||
t.Logf("✅ Successfully parsed real device info XML")
|
||||
t.Logf(" Device ID (MAC): %s", infoXML.DeviceID)
|
||||
t.Logf(" Device Name: %s", infoXML.Name)
|
||||
t.Logf(" Product: %s %s", infoXML.Type, infoXML.ModuleType)
|
||||
t.Logf(" Account: %s", infoXML.MargeAccountUUID)
|
||||
t.Logf(" Primary MAC: %s", primaryMAC)
|
||||
t.Logf(" Component Serial: %s", infoXML.SerialNumber)
|
||||
t.Logf(" Software Version: %s", infoXML.SoftwareVer)
|
||||
}
|
||||
|
||||
func TestDeviceInfoXML_GetPrimaryMacAddress_EdgeCases(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
networkInfo []struct {
|
||||
Type string
|
||||
MacAddress string
|
||||
IPAddress string
|
||||
}
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "no_network_info",
|
||||
networkInfo: nil,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "scm_first",
|
||||
networkInfo: []struct {
|
||||
Type string
|
||||
MacAddress string
|
||||
IPAddress string
|
||||
}{
|
||||
{"SCM", "A81B6A536A98", "192.168.1.1"},
|
||||
{"SMSC", "A81B6A849D99", "192.168.1.1"},
|
||||
},
|
||||
expected: "A81B6A536A98",
|
||||
},
|
||||
{
|
||||
name: "scm_second",
|
||||
networkInfo: []struct {
|
||||
Type string
|
||||
MacAddress string
|
||||
IPAddress string
|
||||
}{
|
||||
{"SMSC", "A81B6A849D99", "192.168.1.1"},
|
||||
{"SCM", "A81B6A536A98", "192.168.1.1"},
|
||||
},
|
||||
expected: "A81B6A536A98",
|
||||
},
|
||||
{
|
||||
name: "no_scm",
|
||||
networkInfo: []struct {
|
||||
Type string
|
||||
MacAddress string
|
||||
IPAddress string
|
||||
}{
|
||||
{"SMSC", "A81B6A849D99", "192.168.1.1"},
|
||||
{"OTHER", "A81B6A849D88", "192.168.1.1"},
|
||||
},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "scm_empty_mac",
|
||||
networkInfo: []struct {
|
||||
Type string
|
||||
MacAddress string
|
||||
IPAddress string
|
||||
}{
|
||||
{"SCM", "", "192.168.1.1"},
|
||||
{"SMSC", "A81B6A849D99", "192.168.1.1"},
|
||||
},
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
info := DeviceInfoXML{}
|
||||
for _, net := range tc.networkInfo {
|
||||
info.NetworkInfo = append(info.NetworkInfo, struct {
|
||||
Type string `xml:"type,attr"`
|
||||
MacAddress string `xml:"macAddress"`
|
||||
IPAddress string `xml:"ipAddress"`
|
||||
}{
|
||||
Type: net.Type,
|
||||
MacAddress: net.MacAddress,
|
||||
IPAddress: net.IPAddress,
|
||||
})
|
||||
}
|
||||
|
||||
result := info.GetPrimaryMacAddress()
|
||||
if result != tc.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceInfoXML_ComponentParsing(t *testing.T) {
|
||||
xmlData := `<info deviceID="A81B6A536A98">
|
||||
<name>Test Device</name>
|
||||
<type>SoundTouch 10</type>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>27.0.6.46330.5043500</softwareVersion>
|
||||
<serialNumber>I6332527703739342000020</serialNumber>
|
||||
</component>
|
||||
<component>
|
||||
<componentCategory>PackagedProduct</componentCategory>
|
||||
<serialNumber>069231P63364828AE</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
</info>`
|
||||
|
||||
manager := NewManager("http://localhost:8000", nil, nil)
|
||||
|
||||
var infoXML DeviceInfoXML
|
||||
if err := manager.parseDeviceInfoXML(strings.NewReader(xmlData), &infoXML); err != nil {
|
||||
t.Fatalf("Failed to parse XML: %v", err)
|
||||
}
|
||||
|
||||
// Verify that SerialNumber and SoftwareVer are populated from components
|
||||
if infoXML.SerialNumber != "I6332527703739342000020" {
|
||||
t.Errorf("Expected SerialNumber 'I6332527703739342000020', got '%s'", infoXML.SerialNumber)
|
||||
}
|
||||
|
||||
if infoXML.SoftwareVer != "27.0.6.46330.5043500" {
|
||||
t.Errorf("Expected SoftwareVer '27.0.6.46330.5043500', got '%s'", infoXML.SoftwareVer)
|
||||
}
|
||||
}
|
||||
+141
-310
@@ -4,8 +4,6 @@ package setup
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -14,6 +12,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
|
||||
@@ -71,8 +70,6 @@ type MigrationSummary struct {
|
||||
CurrentResolvConf string `json:"current_resolv_conf,omitempty"`
|
||||
PlannedResolv string `json:"planned_resolv,omitempty"`
|
||||
IsMigrated bool `json:"is_migrated"`
|
||||
MirrorEnabled bool `json:"mirror_enabled"`
|
||||
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
|
||||
}
|
||||
|
||||
// SSHClient defines the interface for SSH operations.
|
||||
@@ -90,13 +87,6 @@ type Manager struct {
|
||||
|
||||
// GetDNSRunning is an optional callback to check the actual state of the DNS server.
|
||||
GetDNSRunning func() (bool, string)
|
||||
|
||||
// HTTPGet is an optional override for http.Get (primarily for testing).
|
||||
HTTPGet func(url string) (*http.Response, error)
|
||||
|
||||
// Spotify management credentials for the boot primer
|
||||
MgmtUsername string
|
||||
MgmtPassword string
|
||||
}
|
||||
|
||||
// NewManager creates a new Manager with the given base server URL.
|
||||
@@ -108,9 +98,6 @@ func NewManager(serverURL string, ds *datastore.DataStore, cm *certmanager.Certi
|
||||
NewSSH: func(host string) SSHClient {
|
||||
return ssh.NewClient(host)
|
||||
},
|
||||
HTTPGet: http.Get,
|
||||
MgmtUsername: "admin",
|
||||
MgmtPassword: "change_me!",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,25 +107,19 @@ type DeviceInfoXML struct {
|
||||
DeviceID string `xml:"deviceID,attr" json:"deviceID"`
|
||||
Name string `xml:"name" json:"name"`
|
||||
Type string `xml:"type" json:"type"`
|
||||
ModuleType string `xml:"moduleType" json:"moduleType"`
|
||||
MaccAddress string `xml:"maccAddress" json:"maccAddress"`
|
||||
SoftwareVer string `xml:"-" json:"softwareVersion"`
|
||||
SerialNumber string `xml:"-" json:"serialNumber"`
|
||||
MargeAccountUUID string `xml:"margeAccountUUID" json:"margeAccountUUID"`
|
||||
MargeURL string `xml:"margeURL" json:"margeURL"`
|
||||
CountryCode string `xml:"countryCode" json:"countryCode"`
|
||||
RegionCode string `xml:"regionCode" json:"regionCode"`
|
||||
Variant string `xml:"variant" json:"variant"`
|
||||
VariantMode string `xml:"variantMode" json:"variantMode"`
|
||||
Components []struct {
|
||||
Category string `xml:"componentCategory"`
|
||||
SoftwareVersion string `xml:"softwareVersion"`
|
||||
SerialNumber string `xml:"serialNumber"`
|
||||
} `xml:"components>component" json:"-"`
|
||||
NetworkInfo []struct {
|
||||
Type string `xml:"type,attr"`
|
||||
MacAddress string `xml:"macAddress"`
|
||||
IPAddress string `xml:"ipAddress"`
|
||||
} `xml:"networkInfo" json:"networkInfo"`
|
||||
SoftwareVer string `xml:"-" json:"softwareVersion"`
|
||||
SerialNumber string `xml:"-" json:"serialNumber"`
|
||||
|
||||
// Enriched fields (not part of device /info XML)
|
||||
NowPlaying *models.NowPlaying `json:"nowPlaying,omitempty"`
|
||||
Volume *models.Volume `json:"volume,omitempty"`
|
||||
}
|
||||
|
||||
// GetLiveDeviceInfo fetches live information from the speaker's :8090/info endpoint.
|
||||
@@ -150,7 +131,7 @@ func (m *Manager) GetLiveDeviceInfo(deviceIP string) (*DeviceInfoXML, error) {
|
||||
_ = host
|
||||
}
|
||||
|
||||
resp, err := m.HTTPGet(infoURL)
|
||||
resp, err := http.Get(infoURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch info from %s: %w", infoURL, err)
|
||||
}
|
||||
@@ -158,20 +139,10 @@ func (m *Manager) GetLiveDeviceInfo(deviceIP string) (*DeviceInfoXML, error) {
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var infoXML DeviceInfoXML
|
||||
if err := m.parseDeviceInfoXML(resp.Body, &infoXML); err != nil {
|
||||
if err := xml.NewDecoder(resp.Body).Decode(&infoXML); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode info XML from %s: %w", infoURL, err)
|
||||
}
|
||||
|
||||
return &infoXML, nil
|
||||
}
|
||||
|
||||
// parseDeviceInfoXML is a helper method for parsing device info XML from a reader
|
||||
func (m *Manager) parseDeviceInfoXML(reader io.Reader, infoXML *DeviceInfoXML) error {
|
||||
if err := xml.NewDecoder(reader).Decode(infoXML); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract data from components
|
||||
for _, comp := range infoXML.Components {
|
||||
switch comp.Category {
|
||||
case "SCM":
|
||||
@@ -186,18 +157,17 @@ func (m *Manager) parseDeviceInfoXML(reader io.Reader, infoXML *DeviceInfoXML) e
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPrimaryMacAddress returns the primary MAC address from the SCM network interface.
|
||||
func (d *DeviceInfoXML) GetPrimaryMacAddress() string {
|
||||
for _, net := range d.NetworkInfo {
|
||||
if net.Type == "SCM" && net.MacAddress != "" {
|
||||
return net.MacAddress
|
||||
}
|
||||
// Enrich with live now playing and volume via device API (best-effort)
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
if vol, err := c.GetVolume(); err == nil {
|
||||
infoXML.Volume = vol
|
||||
}
|
||||
|
||||
return ""
|
||||
if np, err := c.GetNowPlaying(); err == nil {
|
||||
infoXML.NowPlaying = np
|
||||
}
|
||||
|
||||
return &infoXML, nil
|
||||
}
|
||||
|
||||
// GetMigrationSummary returns a summary of the current and planned state of the speaker.
|
||||
@@ -317,15 +287,6 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
|
||||
// 6. Check if migrated
|
||||
m.checkIsMigrated(summary, deviceIP)
|
||||
|
||||
// 7. Mirroring settings
|
||||
if m.DataStore != nil {
|
||||
settings, err := m.DataStore.GetSettings()
|
||||
if err == nil {
|
||||
summary.MirrorEnabled = settings.MirrorEnabled
|
||||
summary.MirrorEndpoints = settings.MirrorEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
@@ -335,87 +296,73 @@ func (m *Manager) checkIsMigrated(summary *MigrationSummary, deviceIP string) {
|
||||
return
|
||||
}
|
||||
|
||||
client := m.NewSSH(deviceIP)
|
||||
|
||||
if m.isXMLMigrated(summary) || m.isHostsMigrated(client, summary) || m.isResolvConfMigrated(client, summary) {
|
||||
summary.IsMigrated = true
|
||||
}
|
||||
}
|
||||
|
||||
// isXMLMigrated checks whether current XML config already points to our server.
|
||||
func (m *Manager) isXMLMigrated(summary *MigrationSummary) bool {
|
||||
if summary.ParsedCurrentConfig == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
parsedTarget, err := url.Parse(m.ServerURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
targetHost := parsedTarget.Hostname()
|
||||
|
||||
return strings.Contains(summary.ParsedCurrentConfig.MargeServerUrl, targetHost) ||
|
||||
strings.Contains(summary.ParsedCurrentConfig.StatsServerUrl, targetHost) ||
|
||||
strings.Contains(summary.ParsedCurrentConfig.SwUpdateUrl, targetHost) ||
|
||||
strings.Contains(summary.ParsedCurrentConfig.BmxRegistryUrl, targetHost)
|
||||
}
|
||||
|
||||
// isHostsMigrated checks if /etc/hosts contains Bose domain redirections and CA is trusted.
|
||||
func (m *Manager) isHostsMigrated(client SSHClient, summary *MigrationSummary) bool {
|
||||
hostsContent, err := client.Run("cat /etc/hosts")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
boseDomains := []string{
|
||||
"streaming.bose.com",
|
||||
"updates.bose.com",
|
||||
"stats.bose.com",
|
||||
"bmx.bose.com",
|
||||
}
|
||||
for _, domain := range boseDomains {
|
||||
if strings.Contains(hostsContent, domain) && summary.CACertTrusted {
|
||||
return true
|
||||
// Case 1: XML Migration
|
||||
// Check if any URL in the current config points to our server (targetURL)
|
||||
if summary.ParsedCurrentConfig != nil {
|
||||
targetURL := m.ServerURL
|
||||
// Strip protocol for comparison if needed, or just check for substring
|
||||
parsedTarget, err := url.Parse(targetURL)
|
||||
if err == nil {
|
||||
targetHost := parsedTarget.Hostname()
|
||||
if strings.Contains(summary.ParsedCurrentConfig.MargeServerUrl, targetHost) ||
|
||||
strings.Contains(summary.ParsedCurrentConfig.StatsServerUrl, targetHost) ||
|
||||
strings.Contains(summary.ParsedCurrentConfig.SwUpdateUrl, targetHost) ||
|
||||
strings.Contains(summary.ParsedCurrentConfig.BmxRegistryUrl, targetHost) {
|
||||
summary.IsMigrated = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
// Case 2: /etc/hosts + Trust CA Migration
|
||||
// Check if /etc/hosts contains redirections for Bose domains
|
||||
client := m.NewSSH(deviceIP)
|
||||
|
||||
// isResolvConfMigrated checks for Aftertouch DNS migration signals and CA trust.
|
||||
func (m *Manager) isResolvConfMigrated(client SSHClient, summary *MigrationSummary) bool {
|
||||
// Hook file present
|
||||
if _, err := client.Run("[ -f /mnt/nv/aftertouch.resolv.conf ]"); err == nil {
|
||||
return summary.CACertTrusted
|
||||
hostsContent, err := client.Run("cat /etc/hosts")
|
||||
if err == nil {
|
||||
boseDomains := []string{
|
||||
"streaming.bose.com",
|
||||
"updates.bose.com",
|
||||
"stats.bose.com",
|
||||
"bmx.bose.com",
|
||||
}
|
||||
for _, domain := range boseDomains {
|
||||
if strings.Contains(hostsContent, domain) {
|
||||
// If CA is also trusted, it's a strong indicator of migration
|
||||
if summary.CACertTrusted {
|
||||
summary.IsMigrated = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if summary.CurrentResolvConf == "" {
|
||||
return false
|
||||
}
|
||||
// Case 3: /etc/resolv.conf Migration (including Aftertouch hook)
|
||||
// Check if /etc/resolv.conf contains our target nameserver OR if hook marker exists
|
||||
if summary.SSHSuccess {
|
||||
// Check for aftertouch.resolv.conf
|
||||
if _, err := client.Run("[ -f /mnt/nv/aftertouch.resolv.conf ]"); err == nil {
|
||||
if summary.CACertTrusted {
|
||||
summary.IsMigrated = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Marker comment present
|
||||
if strings.Contains(summary.CurrentResolvConf, "# Priority nameserver for Bose service redirection") && summary.CACertTrusted {
|
||||
return true
|
||||
}
|
||||
if summary.CurrentResolvConf != "" {
|
||||
targetURL := m.ServerURL
|
||||
|
||||
// Match hostname or resolved IP
|
||||
parsedTarget, err := url.Parse(m.ServerURL)
|
||||
if err != nil {
|
||||
return false
|
||||
parsedTarget, err := url.Parse(targetURL)
|
||||
if err == nil {
|
||||
targetHost := parsedTarget.Hostname()
|
||||
if strings.Contains(summary.CurrentResolvConf, targetHost) {
|
||||
if summary.CACertTrusted {
|
||||
summary.IsMigrated = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
targetHost := parsedTarget.Hostname()
|
||||
if strings.Contains(summary.CurrentResolvConf, targetHost) && summary.CACertTrusted {
|
||||
return true
|
||||
}
|
||||
|
||||
resolvedIP := m.resolveIP(targetHost, client)
|
||||
if resolvedIP != "" && strings.Contains(summary.CurrentResolvConf, resolvedIP) && summary.CACertTrusted {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// populateDeviceInfo fills in device information from datastore and live info
|
||||
@@ -792,20 +739,6 @@ func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options ma
|
||||
logs += fmt.Sprintf("Warning: could not verify configuration on device: %v\n", err)
|
||||
}
|
||||
|
||||
// 3. Inject CA Certificate (optional but recommended)
|
||||
summary := &MigrationSummary{}
|
||||
m.checkCACertTrusted(summary, deviceIP)
|
||||
|
||||
if !summary.CACertTrusted {
|
||||
out, err := m.TrustCACert(deviceIP)
|
||||
|
||||
logs += "Trusting CA:\n" + out + "\n"
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to trust CA: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
@@ -1199,18 +1132,18 @@ func (m *Manager) migrateViaResolvConf(deviceIP, targetURL string) (string, erro
|
||||
hostIP := m.resolveIP(hostName, client)
|
||||
logs += fmt.Sprintf("Resolved %s to %s\n", hostName, hostIP)
|
||||
|
||||
// 2. Prepare /mnt/nv/soundtouch-service/aftertouch.resolv.conf content
|
||||
// 2. Prepare /mnt/nv/aftertouch.resolv.conf content
|
||||
resolvContent := fmt.Sprintf("# Created by Aftertouch/SoundTouch-Service\n# Priority nameserver for Bose service redirection\nnameserver %s\n", hostIP)
|
||||
|
||||
// 3. Upload /mnt/nv/soundtouch-service/aftertouch.resolv.conf
|
||||
// Ensure /mnt/nv/soundtouch-service exists
|
||||
_, _ = client.Run("mkdir -p /mnt/nv/soundtouch-service")
|
||||
// 3. Upload /mnt/nv/aftertouch.resolv.conf
|
||||
// Ensure /mnt/nv exists
|
||||
_, _ = client.Run("mkdir -p /mnt/nv")
|
||||
|
||||
if uploadErr := client.UploadContent([]byte(resolvContent), "/mnt/nv/soundtouch-service/aftertouch.resolv.conf"); uploadErr != nil {
|
||||
return logs, fmt.Errorf("failed to upload /mnt/nv/soundtouch-service/aftertouch.resolv.conf: %w", uploadErr)
|
||||
if uploadErr := client.UploadContent([]byte(resolvContent), "/mnt/nv/aftertouch.resolv.conf"); uploadErr != nil {
|
||||
return logs, fmt.Errorf("failed to upload /mnt/nv/aftertouch.resolv.conf: %w", uploadErr)
|
||||
}
|
||||
|
||||
logs += "Uploaded /mnt/nv/soundtouch-service/aftertouch.resolv.conf\n"
|
||||
logs += "Uploaded /mnt/nv/aftertouch.resolv.conf\n"
|
||||
|
||||
// 4. Update /mnt/nv/rc.local with idempotent patch
|
||||
patchOut, err := m.updateRcLocalWithDNSHook(client)
|
||||
@@ -1220,14 +1153,11 @@ func (m *Manager) migrateViaResolvConf(deviceIP, targetURL string) (string, erro
|
||||
return logs, err
|
||||
}
|
||||
|
||||
// 5. Cleanup legacy file
|
||||
_, _ = client.Run("rm -f /mnt/nv/aftertouch.resolv.conf")
|
||||
|
||||
// 6. Apply patch immediately to /etc/udhcpc.d/50default
|
||||
// 5. Apply patch immediately to /etc/udhcpc.d/50default
|
||||
rwOut, _ := client.Run(rwCmd)
|
||||
logs += rwCmd + ": " + rwOut + "\n"
|
||||
|
||||
hookMarker := "/mnt/nv/soundtouch-service/aftertouch.resolv.conf"
|
||||
hookMarker := "/mnt/nv/aftertouch.resolv.conf"
|
||||
targetDHCPFile := "/etc/udhcpc.d/50default"
|
||||
dhcpPatchOut, err := m.patchDHCPFile(client, targetDHCPFile, hookMarker)
|
||||
logs += dhcpPatchOut
|
||||
@@ -1270,7 +1200,7 @@ func (m *Manager) updateRcLocalWithDNSHook(client SSHClient) (string, error) {
|
||||
|
||||
rcLocalPath := "/mnt/nv/rc.local"
|
||||
targetDHCPFile := "/etc/udhcpc.d/50default"
|
||||
hookMarker := "/mnt/nv/soundtouch-service/aftertouch.resolv.conf"
|
||||
hookMarker := "/mnt/nv/aftertouch.resolv.conf"
|
||||
|
||||
// Check if rc.local exists and read it
|
||||
currentRcLocal, rcErr := client.Run(fmt.Sprintf("cat %s", rcLocalPath))
|
||||
@@ -1282,12 +1212,8 @@ func (m *Manager) updateRcLocalWithDNSHook(client SSHClient) (string, error) {
|
||||
return fmt.Sprintf("%s already contains Aftertouch hook logic\n", rcLocalPath), nil
|
||||
}
|
||||
|
||||
patchStartMarker := "# --- Aftertouch DNS hook START ---"
|
||||
patchEndMarker := "# --- Aftertouch DNS hook END ---"
|
||||
|
||||
patchLogic := fmt.Sprintf(`
|
||||
%s
|
||||
# prioritizes our custom nameserver if it exists
|
||||
# Aftertouch DNS hook: prioritizes our custom nameserver if it exists
|
||||
if [ -f "%s" ]; then
|
||||
if [ -f "%s" ] && ! grep -q "%s" "%s"; then
|
||||
logger -t "aftertouch" "Patching %s with Aftertouch DNS hook"
|
||||
@@ -1299,47 +1225,9 @@ if [ -f "%s" ]; then
|
||||
sed -i '/echo "search \$search_list # \$interface" >> \$RESOLV_CONF/a \ [ -f '"%s"' ] && cat '"%s"' >> '"\$RESOLV_CONF"' && dns=""' "$targetScript"
|
||||
fi
|
||||
fi
|
||||
%s
|
||||
`, patchStartMarker, hookMarker, targetDHCPFile, hookMarker, targetDHCPFile, targetDHCPFile, hookMarker, hookMarker, targetDHCPFile, hookMarker, hookMarker, hookMarker, patchEndMarker)
|
||||
`, hookMarker, targetDHCPFile, hookMarker, targetDHCPFile, targetDHCPFile, hookMarker, hookMarker, targetDHCPFile, hookMarker, hookMarker, hookMarker)
|
||||
|
||||
newRcLocal := currentRcLocal
|
||||
// Remove old-style DNS hook if it exists
|
||||
if strings.Contains(newRcLocal, "# Aftertouch DNS hook") && !strings.Contains(newRcLocal, patchStartMarker) {
|
||||
// Old removal: filter out lines between the marker and the first 'fi'
|
||||
lines := strings.Split(newRcLocal, "\n")
|
||||
|
||||
var filteredLines []string
|
||||
|
||||
skip := false
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "# Aftertouch DNS hook") {
|
||||
skip = true
|
||||
continue
|
||||
}
|
||||
|
||||
if skip && strings.TrimSpace(line) == "fi" {
|
||||
skip = false
|
||||
continue
|
||||
}
|
||||
|
||||
if !skip {
|
||||
filteredLines = append(filteredLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
newRcLocal = strings.Join(filteredLines, "\n")
|
||||
}
|
||||
|
||||
// Remove existing marker-based hook if it exists (for update)
|
||||
if strings.Contains(newRcLocal, patchStartMarker) {
|
||||
startIdx := strings.Index(newRcLocal, patchStartMarker)
|
||||
|
||||
endIdx := strings.Index(newRcLocal, patchEndMarker)
|
||||
if startIdx != -1 && endIdx != -1 {
|
||||
newRcLocal = newRcLocal[:startIdx] + newRcLocal[endIdx+len(patchEndMarker):]
|
||||
}
|
||||
}
|
||||
// Remove "cat: can't open..." error message if it was accidentally saved in the file
|
||||
if strings.Contains(newRcLocal, "cat: can't open") {
|
||||
newRcLocal = ""
|
||||
@@ -1519,20 +1407,58 @@ func (m *Manager) revertResolvConf(client SSHClient, rwCmd string) string {
|
||||
func (m *Manager) revertAftertouchHook(client SSHClient, rwCmd string) string {
|
||||
var logs string
|
||||
|
||||
aftertouchConfPath := "/mnt/nv/soundtouch-service/aftertouch.resolv.conf"
|
||||
legacyConfPath := "/mnt/nv/aftertouch.resolv.conf"
|
||||
aftertouchConfPath := "/mnt/nv/aftertouch.resolv.conf"
|
||||
rcLocalPath := "/mnt/nv/rc.local"
|
||||
targetDHCPFile := "/etc/udhcpc.d/50default"
|
||||
|
||||
for _, p := range []string{aftertouchConfPath, legacyConfPath} {
|
||||
if _, err := client.Run(fmt.Sprintf("[ -f %s ]", p)); err == nil {
|
||||
logs += fmt.Sprintf("Removing %s\n", p)
|
||||
fmt.Printf("Removing %s\n", p)
|
||||
_, _ = client.Run(fmt.Sprintf("rm %s", p))
|
||||
}
|
||||
if _, err := client.Run(fmt.Sprintf("[ -f %s ]", aftertouchConfPath)); err == nil {
|
||||
logs += fmt.Sprintf("Removing %s\n", aftertouchConfPath)
|
||||
fmt.Printf("Removing %s\n", aftertouchConfPath)
|
||||
_, _ = client.Run(fmt.Sprintf("rm %s", aftertouchConfPath))
|
||||
}
|
||||
|
||||
logs += m.removeRcLocalHooks(client, rcLocalPath)
|
||||
if currentRcLocal, err := client.Run(fmt.Sprintf("cat %s", rcLocalPath)); err == nil {
|
||||
// Remove "cat: can't open..." error message if it was accidentally saved in the file
|
||||
if strings.Contains(currentRcLocal, "cat: can't open") {
|
||||
logs += fmt.Sprintf("Removing corrupted %s\n", rcLocalPath)
|
||||
_, _ = client.Run(fmt.Sprintf("rm %s", rcLocalPath))
|
||||
|
||||
return logs
|
||||
}
|
||||
|
||||
if strings.Contains(currentRcLocal, aftertouchConfPath) || strings.Contains(currentRcLocal, "# Aftertouch DNS hook") {
|
||||
logs += fmt.Sprintf("Removing Aftertouch hook logic from %s\n", rcLocalPath)
|
||||
fmt.Printf("Removing Aftertouch hook logic from %s\n", rcLocalPath)
|
||||
|
||||
// Simple removal: filter out lines between the marker and the 'fi'
|
||||
lines := strings.Split(currentRcLocal, "\n")
|
||||
|
||||
var newLines []string
|
||||
|
||||
skip := false
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "# Aftertouch DNS hook") {
|
||||
skip = true
|
||||
continue
|
||||
}
|
||||
|
||||
if skip && strings.TrimSpace(line) == "fi" {
|
||||
skip = false
|
||||
continue
|
||||
}
|
||||
|
||||
if !skip {
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
newRcLocal := strings.Join(newLines, "\n")
|
||||
if err := client.UploadContent([]byte(newRcLocal), rcLocalPath); err != nil {
|
||||
fmt.Printf("Warning: failed to update %s: %v\n", rcLocalPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", targetDHCPFile)); err == nil {
|
||||
logs += fmt.Sprintf("Reverting %s from backup\n", targetDHCPFile)
|
||||
@@ -1560,94 +1486,6 @@ func (m *Manager) revertAftertouchHook(client SSHClient, rwCmd string) string {
|
||||
return logs
|
||||
}
|
||||
|
||||
func (m *Manager) removeRcLocalHooks(client SSHClient, rcLocalPath string) string {
|
||||
var logs string
|
||||
|
||||
patchStartMarker := "# --- Aftertouch DNS hook START ---"
|
||||
patchEndMarker := "# --- Aftertouch DNS hook END ---"
|
||||
spotifyPatchStartMarker := "# --- Aftertouch Spotify hook START ---"
|
||||
spotifyPatchEndMarker := "# --- Aftertouch Spotify hook END ---"
|
||||
aftertouchConfPath := "/mnt/nv/soundtouch-service/aftertouch.resolv.conf"
|
||||
legacyAftertouchConfPath := "/mnt/nv/aftertouch.resolv.conf"
|
||||
|
||||
currentRcLocal, err := client.Run(fmt.Sprintf("cat %s", rcLocalPath))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Remove "cat: can't open..." error message if it was accidentally saved in the file
|
||||
if strings.Contains(currentRcLocal, "cat: can't open") {
|
||||
logs += fmt.Sprintf("Removing corrupted %s\n", rcLocalPath)
|
||||
_, _ = client.Run(fmt.Sprintf("rm %s", rcLocalPath))
|
||||
|
||||
return logs
|
||||
}
|
||||
|
||||
modified := false
|
||||
|
||||
if strings.Contains(currentRcLocal, patchStartMarker) {
|
||||
logs += fmt.Sprintf("Removing Aftertouch hook logic from %s\n", rcLocalPath)
|
||||
fmt.Printf("Removing Aftertouch hook logic from %s\n", rcLocalPath)
|
||||
|
||||
startIdx := strings.Index(currentRcLocal, patchStartMarker)
|
||||
endIdx := strings.Index(currentRcLocal, patchEndMarker)
|
||||
|
||||
if startIdx != -1 && endIdx != -1 {
|
||||
currentRcLocal = currentRcLocal[:startIdx] + currentRcLocal[endIdx+len(patchEndMarker):]
|
||||
modified = true
|
||||
}
|
||||
} else if strings.Contains(currentRcLocal, aftertouchConfPath) || strings.Contains(currentRcLocal, legacyAftertouchConfPath) || strings.Contains(currentRcLocal, "# Aftertouch DNS hook") {
|
||||
logs += fmt.Sprintf("Removing legacy Aftertouch hook logic from %s\n", rcLocalPath)
|
||||
fmt.Printf("Removing legacy Aftertouch hook logic from %s\n", rcLocalPath)
|
||||
|
||||
lines := strings.Split(currentRcLocal, "\n")
|
||||
|
||||
var newLines []string
|
||||
|
||||
skip := false
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "# Aftertouch DNS hook") {
|
||||
skip = true
|
||||
continue
|
||||
}
|
||||
|
||||
if skip && strings.TrimSpace(line) == "fi" {
|
||||
skip = false
|
||||
continue
|
||||
}
|
||||
|
||||
if !skip {
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
currentRcLocal = strings.Join(newLines, "\n")
|
||||
modified = true
|
||||
}
|
||||
|
||||
if strings.Contains(currentRcLocal, spotifyPatchStartMarker) {
|
||||
logs += fmt.Sprintf("Removing Spotify hook logic from %s\n", rcLocalPath)
|
||||
fmt.Printf("Removing Spotify hook logic from %s\n", rcLocalPath)
|
||||
|
||||
startIdx := strings.Index(currentRcLocal, spotifyPatchStartMarker)
|
||||
endIdx := strings.Index(currentRcLocal, spotifyPatchEndMarker)
|
||||
|
||||
if startIdx != -1 && endIdx != -1 {
|
||||
currentRcLocal = currentRcLocal[:startIdx] + currentRcLocal[endIdx+len(spotifyPatchEndMarker):]
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
|
||||
if modified {
|
||||
if err := client.UploadContent([]byte(currentRcLocal), rcLocalPath); err != nil {
|
||||
fmt.Printf("Warning: failed to update %s: %v\n", rcLocalPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return logs
|
||||
}
|
||||
|
||||
func (m *Manager) revertCACert(client SSHClient, rwCmd string) string {
|
||||
var logs string
|
||||
|
||||
@@ -2065,20 +1903,13 @@ func (m *Manager) SyncDeviceData(deviceIP string) error {
|
||||
return fmt.Errorf("failed to get device info: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Starting sync for device at %s: Name='%s', DeviceID='%s', SerialNumber='%s'",
|
||||
deviceIP, info.Name, info.DeviceID, info.SerialNumber)
|
||||
|
||||
accountID := ""
|
||||
|
||||
// Use deviceID from /info as canonical identifier (MAC address)
|
||||
deviceID := info.DeviceID
|
||||
deviceID := info.SerialNumber
|
||||
if deviceID == "" {
|
||||
log.Printf("No deviceID found in /info response for device '%s' at %s", info.Name, deviceIP)
|
||||
return fmt.Errorf("no deviceID found in /info response for device at %s - cannot sync without canonical device identifier", deviceIP)
|
||||
deviceID = deviceIP
|
||||
}
|
||||
|
||||
log.Printf("Using deviceID '%s' for sync operations (MAC address from /info)", deviceID)
|
||||
|
||||
if info.MargeAccountUUID != "" {
|
||||
accountID = info.MargeAccountUUID
|
||||
}
|
||||
@@ -2119,7 +1950,7 @@ func (m *Manager) syncPresets(deviceIP, accountID, deviceID string) {
|
||||
presetsURL = fmt.Sprintf("http://%s/presets", deviceIP)
|
||||
}
|
||||
|
||||
resp, err := m.HTTPGet(presetsURL)
|
||||
resp, err := http.Get(presetsURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -2174,7 +2005,7 @@ func (m *Manager) syncRecents(deviceIP, accountID, deviceID string) {
|
||||
recentsURL = fmt.Sprintf("http://%s/recents", deviceIP)
|
||||
}
|
||||
|
||||
resp, err := m.HTTPGet(recentsURL)
|
||||
resp, err := http.Get(recentsURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -2241,7 +2072,7 @@ func (m *Manager) syncSources(deviceIP, accountID, deviceID string) {
|
||||
sourcesURL = fmt.Sprintf("http://%s/sources", deviceIP)
|
||||
}
|
||||
|
||||
resp, err := m.HTTPGet(sourcesURL)
|
||||
resp, err := http.Get(sourcesURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -280,45 +280,6 @@ func TestGetMigrationSummary_WithProxyOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMigrationSummary_MirrorSettings(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-test-mirror-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
settings := datastore.Settings{
|
||||
MirrorEnabled: true,
|
||||
MirrorEndpoints: []string{"/recent", "/presets"},
|
||||
}
|
||||
if err := ds.SaveSettings(settings); err != nil {
|
||||
t.Fatalf("Failed to save settings: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager("http://localhost:8000", ds, nil)
|
||||
|
||||
// Mock server for live info
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = fmt.Fprint(w, `<info deviceID="123"><name>Test</name></info>`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
summary, err := m.GetMigrationSummary(server.Listener.Addr().String(), "", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMigrationSummary failed: %v", err)
|
||||
}
|
||||
|
||||
if !summary.MirrorEnabled {
|
||||
t.Error("Expected MirrorEnabled to be true in summary")
|
||||
}
|
||||
|
||||
if len(summary.MirrorEndpoints) != 2 || summary.MirrorEndpoints[0] != "/recent" {
|
||||
t.Errorf("Expected MirrorEndpoints [/recent /presets], got %v", summary.MirrorEndpoints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCACertTrusted(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "ca-trust-test")
|
||||
if err != nil {
|
||||
@@ -758,7 +719,7 @@ func TestRevertMigration(t *testing.T) {
|
||||
}
|
||||
// Mock file existence checks for .original files
|
||||
if strings.HasPrefix(command, "[ -f") {
|
||||
if strings.Contains(command, ".original") || strings.Contains(command, "/mnt/nv/soundtouch-service/aftertouch.resolv.conf") || strings.Contains(command, "/mnt/nv/aftertouch.resolv.conf") {
|
||||
if strings.Contains(command, ".original") || strings.Contains(command, "/mnt/nv/aftertouch.resolv.conf") {
|
||||
return "", nil // file exists
|
||||
}
|
||||
}
|
||||
@@ -1167,7 +1128,7 @@ func TestMigrateViaResolvConf(t *testing.T) {
|
||||
if command == "cat /mnt/nv/rc.local" {
|
||||
return "#!/bin/sh\n", nil
|
||||
}
|
||||
if strings.HasPrefix(command, "grep -q \"/mnt/nv/soundtouch-service/aftertouch.resolv.conf\"") {
|
||||
if strings.HasPrefix(command, "grep -q \"/mnt/nv/aftertouch.resolv.conf\"") {
|
||||
return "OK", nil
|
||||
}
|
||||
if strings.HasPrefix(command, "[ -f") {
|
||||
@@ -1188,11 +1149,11 @@ func TestMigrateViaResolvConf(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify uploads
|
||||
if !strings.Contains(uploads["/mnt/nv/soundtouch-service/aftertouch.resolv.conf"], "nameserver 192.168.1.100") {
|
||||
if !strings.Contains(uploads["/mnt/nv/aftertouch.resolv.conf"], "nameserver 192.168.1.100") {
|
||||
t.Errorf("aftertouch.resolv.conf missing nameserver")
|
||||
}
|
||||
|
||||
if !strings.Contains(uploads["/mnt/nv/rc.local"], "/mnt/nv/soundtouch-service/aftertouch.resolv.conf") {
|
||||
if !strings.Contains(uploads["/mnt/nv/rc.local"], "/mnt/nv/aftertouch.resolv.conf") {
|
||||
t.Errorf("rc.local missing hook logic")
|
||||
}
|
||||
|
||||
@@ -1232,7 +1193,7 @@ func TestMigrateViaResolvConf_CorruptedRcLocal(t *testing.T) {
|
||||
// Simulate corrupted file containing error message
|
||||
return "cat: can't open '/mnt/nv/rc.local': No such file or directory", nil
|
||||
}
|
||||
if strings.HasPrefix(command, "grep -q \"/mnt/nv/soundtouch-service/aftertouch.resolv.conf\"") {
|
||||
if strings.HasPrefix(command, "grep -q \"/mnt/nv/aftertouch.resolv.conf\"") {
|
||||
return "OK", nil
|
||||
}
|
||||
if strings.HasPrefix(command, "[ -f") {
|
||||
@@ -1260,7 +1221,7 @@ func TestMigrateViaResolvConf_CorruptedRcLocal(t *testing.T) {
|
||||
if !strings.HasPrefix(rcLocal, "#!/bin/sh") {
|
||||
t.Errorf("rc.local missing shebang: %s", rcLocal)
|
||||
}
|
||||
if !strings.Contains(rcLocal, "/mnt/nv/soundtouch-service/aftertouch.resolv.conf") {
|
||||
if !strings.Contains(rcLocal, "/mnt/nv/aftertouch.resolv.conf") {
|
||||
t.Errorf("rc.local missing hook logic: %s", rcLocal)
|
||||
}
|
||||
}
|
||||
@@ -1291,7 +1252,7 @@ func TestMigrateViaResolvConf_UdhcpcScript(t *testing.T) {
|
||||
if command == "cat /mnt/nv/rc.local" {
|
||||
return "#!/bin/sh\n", nil
|
||||
}
|
||||
if strings.HasPrefix(command, "grep -q \"/mnt/nv/soundtouch-service/aftertouch.resolv.conf\"") {
|
||||
if strings.HasPrefix(command, "grep -q \"/mnt/nv/aftertouch.resolv.conf\"") {
|
||||
return "OK", nil
|
||||
}
|
||||
if command == "[ -f "+targetScript+" ]" {
|
||||
@@ -1359,12 +1320,12 @@ func TestRevertMigration_ResolvConf(t *testing.T) {
|
||||
runFunc: func(command string) (string, error) {
|
||||
runCalls = append(runCalls, command)
|
||||
if command == "cat /mnt/nv/rc.local" {
|
||||
return "#!/bin/sh\n# Aftertouch DNS hook\nif [ -f \"/mnt/nv/soundtouch-service/aftertouch.resolv.conf\" ]; then\n sed ...\nfi\n", nil
|
||||
return "#!/bin/sh\n# Aftertouch DNS hook\nif [ -f \"/mnt/nv/aftertouch.resolv.conf\" ]; then\n sed ...\nfi\n", nil
|
||||
}
|
||||
if strings.Contains(command, ".original ]") {
|
||||
return "", nil // backup exists
|
||||
}
|
||||
if strings.Contains(command, "[ -f /mnt/nv/soundtouch-service/aftertouch.resolv.conf ]") || strings.Contains(command, "[ -f /mnt/nv/aftertouch.resolv.conf ]") {
|
||||
if strings.Contains(command, "[ -f /mnt/nv/aftertouch.resolv.conf ]") {
|
||||
return "", nil
|
||||
}
|
||||
return "", nil
|
||||
@@ -1448,58 +1409,6 @@ func TestCheckIsMigrated(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ResolvConf Migrated (Marker)", func(t *testing.T) {
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
if command == "cat /etc/hosts" {
|
||||
return "127.0.0.1\tlocalhost", nil
|
||||
}
|
||||
if command == "[ -f /mnt/nv/aftertouch.resolv.conf ]" {
|
||||
return "", fmt.Errorf("not found")
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
}
|
||||
summary := &MigrationSummary{
|
||||
SSHSuccess: true,
|
||||
CACertTrusted: true,
|
||||
CurrentResolvConf: "# Priority nameserver for Bose service redirection\nnameserver 192.168.1.1\n",
|
||||
}
|
||||
m.checkIsMigrated(summary, "127.0.0.1")
|
||||
if !summary.IsMigrated {
|
||||
t.Errorf("Expected IsMigrated to be true for resolv.conf migration with marker comment")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ResolvConf Migrated (IP)", func(t *testing.T) {
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
if command == "cat /etc/hosts" {
|
||||
return "127.0.0.1\tlocalhost", nil
|
||||
}
|
||||
if command == "[ -f /mnt/nv/aftertouch.resolv.conf ]" {
|
||||
return "", fmt.Errorf("not found")
|
||||
}
|
||||
// Mock resolveIP by mocking its SSH commands if any, or just wait for it to return targetHost
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
}
|
||||
// m.ServerURL is "http://aftertouch:8000" in this test (see top of TestCheckIsMigrated)
|
||||
summary := &MigrationSummary{
|
||||
SSHSuccess: true,
|
||||
CACertTrusted: true,
|
||||
CurrentResolvConf: "nameserver aftertouch\n",
|
||||
}
|
||||
m.checkIsMigrated(summary, "127.0.0.1")
|
||||
if !summary.IsMigrated {
|
||||
t.Errorf("Expected IsMigrated to be true for resolv.conf migration with matching hostname/IP")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Not Migrated", func(t *testing.T) {
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func TestSyncDeviceData_UsesDeviceID(t *testing.T) {
|
||||
// Create a temporary datastore
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
|
||||
// Mock HTTP server that provides device info and presets
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/info":
|
||||
// Return device info with MAC address as deviceID
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="A81B6A536A98">
|
||||
<name>Test Device</name>
|
||||
<type>SoundTouch 30</type>
|
||||
<margeAccountUUID>test-account-123</margeAccountUUID>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SYSTEM</componentCategory>
|
||||
<softwareVersion>4.8.1</softwareVersion>
|
||||
<serialNumber>I6332527703739342000020</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>A81B6A536A98</macAddress>
|
||||
<ipAddress>192.168.1.100</ipAddress>
|
||||
</networkInfo>
|
||||
</info>`)
|
||||
case "/presets":
|
||||
// Return empty presets for simplicity
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets deviceID="A81B6A536A98"/>`)
|
||||
case "/recents":
|
||||
// Return empty recents for simplicity
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<recents deviceID="A81B6A536A98"/>`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Extract host from server URL
|
||||
serverHost := strings.TrimPrefix(server.URL, "http://")
|
||||
|
||||
// Create manager with mock HTTP client
|
||||
cm := certmanager.NewCertificateManager(tmpDir + "/certs")
|
||||
manager := NewManager("http://localhost:8000", ds, cm)
|
||||
|
||||
// Test SyncDeviceData
|
||||
err := manager.SyncDeviceData(serverHost)
|
||||
if err != nil {
|
||||
t.Fatalf("SyncDeviceData failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify that data was synced to the correct directory using MAC address (deviceID)
|
||||
expectedDeviceID := "A81B6A536A98"
|
||||
expectedAccountID := "test-account-123"
|
||||
|
||||
// Check that the device directory is resolved correctly using MAC address
|
||||
deviceDir := ds.AccountDeviceDir(expectedAccountID, expectedDeviceID)
|
||||
if !strings.HasSuffix(deviceDir, fmt.Sprintf("accounts/%s/devices/%s", expectedAccountID, expectedDeviceID)) {
|
||||
t.Errorf("Device directory should be based on MAC address. Got: %s", deviceDir)
|
||||
}
|
||||
|
||||
// Just verify the directory structure was created correctly
|
||||
// The sync process should create directories even for empty data
|
||||
t.Logf("Device directory resolved to: %s", deviceDir)
|
||||
|
||||
// Try to get presets - might not exist if empty, but should not error on directory resolution
|
||||
_, presetsErr := ds.GetPresets(expectedAccountID, expectedDeviceID)
|
||||
if presetsErr != nil && !strings.Contains(presetsErr.Error(), "no such file or directory") {
|
||||
t.Fatalf("Unexpected error getting presets: %v", presetsErr)
|
||||
}
|
||||
|
||||
t.Logf("✓ Sync completed using MAC address as deviceID: %s", expectedDeviceID)
|
||||
t.Logf("✓ Directory structure: %s", deviceDir)
|
||||
}
|
||||
|
||||
func TestSyncDeviceData_NoDeviceID_ShouldFail(t *testing.T) {
|
||||
// Create a temporary datastore
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
|
||||
// Mock HTTP server that provides device info WITHOUT deviceID
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/info" {
|
||||
// Return device info without deviceID (empty deviceID)
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="">
|
||||
<name>Test Device</name>
|
||||
<type>SoundTouch 30</type>
|
||||
<margeAccountUUID>test-account-123</margeAccountUUID>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SYSTEM</componentCategory>
|
||||
<softwareVersion>4.8.1</softwareVersion>
|
||||
<serialNumber>I6332527703739342000020</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
</info>`)
|
||||
} else {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Extract host from server URL
|
||||
serverHost := strings.TrimPrefix(server.URL, "http://")
|
||||
|
||||
// Create manager
|
||||
cm := certmanager.NewCertificateManager(tmpDir + "/certs")
|
||||
manager := NewManager("http://localhost:8000", ds, cm)
|
||||
|
||||
// Test SyncDeviceData - should fail
|
||||
err := manager.SyncDeviceData(serverHost)
|
||||
if err == nil {
|
||||
t.Fatal("SyncDeviceData should have failed when deviceID is empty")
|
||||
}
|
||||
|
||||
expectedErrorSubstring := "no deviceID found in /info response"
|
||||
if !strings.Contains(err.Error(), expectedErrorSubstring) {
|
||||
t.Errorf("Expected error to contain '%s', got: %v", expectedErrorSubstring, err)
|
||||
}
|
||||
|
||||
t.Logf("✓ SyncDeviceData correctly failed with error: %v", err)
|
||||
}
|
||||
|
||||
func TestSyncDeviceData_FallbackToExistingDeviceMapping(t *testing.T) {
|
||||
// Create a temporary datastore
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
|
||||
// Pre-populate device data using serial number (legacy scenario)
|
||||
accountID := "test-account-123"
|
||||
serialNumber := "I6332527703739342000020"
|
||||
macAddress := "A81B6A536A98"
|
||||
|
||||
// Save device info under serial number (simulating legacy behavior)
|
||||
legacyDeviceInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: serialNumber,
|
||||
AccountID: accountID,
|
||||
Name: "Legacy Device",
|
||||
MacAddress: macAddress,
|
||||
DeviceSerialNumber: serialNumber,
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(accountID, serialNumber, legacyDeviceInfo); err != nil {
|
||||
t.Fatalf("Failed to save legacy device info: %v", err)
|
||||
}
|
||||
|
||||
// Also save some legacy presets under the serial number
|
||||
legacyPresets := []models.ServicePreset{
|
||||
{
|
||||
ServiceContentItem: models.ServiceContentItem{
|
||||
ID: "1",
|
||||
Name: "Legacy Preset",
|
||||
Source: "SPOTIFY",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := ds.SavePresets(accountID, serialNumber, legacyPresets); err != nil {
|
||||
t.Fatalf("Failed to save legacy presets: %v", err)
|
||||
}
|
||||
|
||||
// Mock HTTP server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := r.Host // Get IP from request host
|
||||
switch r.URL.Path {
|
||||
case "/info":
|
||||
// Return device info with MAC address as deviceID
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="%s">
|
||||
<name>Updated Device</name>
|
||||
<type>SoundTouch 30</type>
|
||||
<margeAccountUUID>%s</margeAccountUUID>
|
||||
<components>
|
||||
<component>
|
||||
<componentCategory>SCM</componentCategory>
|
||||
<softwareVersion>4.5.2</softwareVersion>
|
||||
<serialNumber>%s</serialNumber>
|
||||
</component>
|
||||
</components>
|
||||
<networkInfo type="SCM">
|
||||
<macAddress>%s</macAddress>
|
||||
<ipAddress>%s</ipAddress>
|
||||
</networkInfo>
|
||||
</info>`, macAddress, accountID, serialNumber, macAddress, deviceIP)
|
||||
case "/presets":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets deviceID="A81B6A536A98"/>`)
|
||||
case "/recents":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<recents deviceID="A81B6A536A98"/>`)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
serverHost := strings.TrimPrefix(server.URL, "http://")
|
||||
cm := certmanager.NewCertificateManager(tmpDir + "/certs")
|
||||
manager := NewManager("http://localhost:8000", ds, cm)
|
||||
|
||||
// Sync should work and use MAC address
|
||||
err := manager.SyncDeviceData(serverHost)
|
||||
if err != nil {
|
||||
t.Fatalf("SyncDeviceData failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify directory resolution - MAC address should resolve to its own directory
|
||||
macDir := ds.AccountDeviceDir(accountID, macAddress)
|
||||
legacyDir := ds.AccountDeviceDir(accountID, serialNumber)
|
||||
|
||||
t.Logf("MAC address resolves to: %s", macDir)
|
||||
t.Logf("Serial number resolves to: %s", legacyDir)
|
||||
|
||||
// The key test: MAC address should create its own directory structure
|
||||
if !strings.Contains(macDir, macAddress) {
|
||||
t.Errorf("MAC address directory should contain MAC address %s, got %s", macAddress, macDir)
|
||||
}
|
||||
|
||||
// Legacy data should still be accessible
|
||||
serialPresets, err := ds.GetPresets(accountID, serialNumber)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get legacy presets by serial number: %v", err)
|
||||
}
|
||||
|
||||
if len(serialPresets) != 1 || serialPresets[0].Name != "Legacy Preset" {
|
||||
t.Errorf("Legacy presets should still be accessible by serial number")
|
||||
}
|
||||
|
||||
t.Logf("✓ Sync successfully used MAC address as deviceID: %s", macAddress)
|
||||
t.Logf("✓ Legacy data still accessible via serial number: %s", serialNumber)
|
||||
}
|
||||
@@ -207,53 +207,39 @@ self_update() {
|
||||
return
|
||||
fi
|
||||
|
||||
log "Newer installer found for ${VERSION}. Updating ${SCRIPT_PATH} and re-executing..."
|
||||
install -m 0755 "${tmp_script}" "${SCRIPT_PATH}"
|
||||
rm -f "${tmp_script}"
|
||||
log "Newer installer found for ${VERSION}. Re-executing..."
|
||||
chmod +x "${tmp_script}"
|
||||
|
||||
# Export current env vars to the new script
|
||||
export IS_SELF_UPDATE="true"
|
||||
export VERSION HOSTNAME_FQDN HTTP_PORT HTTPS_PORT DATA_DIR BIN_PATH CONFIG_DIR ENV_FILE SERVICE_USER SERVICE_GROUP
|
||||
export SPOTIFY_CLIENT_ID SPOTIFY_CLIENT_SECRET SPOTIFY_REDIRECT_URI MGMT_USERNAME MGMT_PASSWORD
|
||||
|
||||
exec "${SCRIPT_PATH}" "$@"
|
||||
exec "${tmp_script}" "$@"
|
||||
}
|
||||
|
||||
write_env_file() {
|
||||
log "Updating env file: ${ENV_FILE}"
|
||||
log "Writing env file: ${ENV_FILE}"
|
||||
cat > "${ENV_FILE}" <<EOF
|
||||
PORT=${HTTP_PORT}
|
||||
HTTPS_PORT=${HTTPS_PORT}
|
||||
DATA_DIR=${DATA_DIR}
|
||||
|
||||
# 1. Start with a list of all variables we want to manage
|
||||
local vars=(
|
||||
"PORT=${HTTP_PORT}"
|
||||
"HTTPS_PORT=${HTTPS_PORT}"
|
||||
"DATA_DIR=${DATA_DIR}"
|
||||
"LOG_PROXY_BODY=${LOG_PROXY_BODY}"
|
||||
"REDACT_PROXY_LOGS=${REDACT_PROXY_LOGS}"
|
||||
"RECORD_INTERACTIONS=${RECORD_INTERACTIONS}"
|
||||
"DISCOVERY_INTERVAL=${DISCOVERY_INTERVAL}"
|
||||
"SERVER_URL=${SERVER_URL}"
|
||||
"HTTPS_SERVER_URL=${HTTPS_SERVER_URL}"
|
||||
"SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}"
|
||||
"SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}"
|
||||
"SPOTIFY_REDIRECT_URI=${SPOTIFY_REDIRECT_URI}"
|
||||
"MGMT_USERNAME=${MGMT_USERNAME}"
|
||||
"MGMT_PASSWORD=${MGMT_PASSWORD}"
|
||||
)
|
||||
LOG_PROXY_BODY=${LOG_PROXY_BODY}
|
||||
REDACT_PROXY_LOGS=${REDACT_PROXY_LOGS}
|
||||
RECORD_INTERACTIONS=${RECORD_INTERACTIONS}
|
||||
DISCOVERY_INTERVAL=${DISCOVERY_INTERVAL}
|
||||
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
for entry in "${vars[@]}"; do
|
||||
echo "${entry}" >> "${ENV_FILE}"
|
||||
done
|
||||
else
|
||||
for entry in "${vars[@]}"; do
|
||||
local key="${entry%%=*}"
|
||||
local val="${entry#*=}"
|
||||
if ! grep -q "^${key}=" "${ENV_FILE}"; then
|
||||
echo "${key}=${val}" >> "${ENV_FILE}"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
SERVER_URL=${SERVER_URL}
|
||||
HTTPS_SERVER_URL=${HTTPS_SERVER_URL}
|
||||
|
||||
SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}
|
||||
SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}
|
||||
SPOTIFY_REDIRECT_URI=${SPOTIFY_REDIRECT_URI}
|
||||
|
||||
MGMT_USERNAME=${MGMT_USERNAME}
|
||||
MGMT_PASSWORD=${MGMT_PASSWORD}
|
||||
EOF
|
||||
chmod 0640 "${ENV_FILE}"
|
||||
# group-readable so you can add yourself to the group if desired
|
||||
chown root:"${SERVICE_GROUP}" "${ENV_FILE}" || true
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
# On-Speaker Spotify Boot Primer for Bose SoundTouch
|
||||
Self-contained boot-time Spotify primer that runs directly on the speaker.
|
||||
No Spotify credentials on the device — it fetches a fresh token from a
|
||||
[Bose-SoundTouch](https://github.com/gesellix/Bose-SoundTouch) server at boot.
|
||||
No jq, no rootfs modification — just files on persistent storage.
|
||||
|
||||
## How It Works
|
||||
Bose SoundTouch speakers run embedded Linux with a persistent writable volume
|
||||
at `/mnt/nv`. The init script `shelby_local` (S97) has a built-in hook:
|
||||
```
|
||||
[ -x /mnt/nv/rc.local ] && /mnt/nv/rc.local
|
||||
```
|
||||
This runs before SoundTouch itself (S99), so we background a primer script
|
||||
that waits for the Spotify Connect ZeroConf endpoint (port 8200) to come up,
|
||||
fetches a fresh Spotify token from the service, and primes the speaker — all
|
||||
within ~30 seconds of boot.
|
||||
|
||||
## File Layout
|
||||
```
|
||||
/mnt/nv/
|
||||
rc.local boot hook (S97 checks this)
|
||||
.profile PATH setup for interactive SSH
|
||||
bin/
|
||||
spotify-boot-primer main script
|
||||
BoseApp-Persistence/1/
|
||||
spotify-primer.conf service credentials (mode 600)
|
||||
Sources.xml, Presets.xml, ... existing speaker data
|
||||
```
|
||||
Scripts live in `/mnt/nv/bin/` (added to PATH via `.profile`), config lives
|
||||
alongside the speaker's own persistence files in `/mnt/nv/BoseApp-Persistence/1/`.
|
||||
|
||||
## Speaker Environment
|
||||
Tested on SoundTouch 20. Other SoundTouch models likely similar.
|
||||
| Item | Detail |
|
||||
|------|--------|
|
||||
| OS | Linux 3.14.43+ ARM (hostname `spotty`) |
|
||||
| Root FS | Read-only ubifs (can be remounted rw) |
|
||||
| Persistent storage | `/mnt/nv` — writable ubifs, ~24M free |
|
||||
| curl | 7.50.3 with OpenSSL (HTTPS works) |
|
||||
| bash/grep/sed/awk | Available via busybox |
|
||||
| jq | **Not available** (not needed) |
|
||||
| Init | SysV, runlevel 5 |
|
||||
| Production mode | Yes — cron is disabled |
|
||||
|
||||
## Prerequisites
|
||||
1. **SSH access to the speaker**:
|
||||
```
|
||||
ssh -o HostKeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa root@SPEAKER_IP
|
||||
```
|
||||
2. **A running [Bose-SoundTouch](https://github.com/gesellix/Bose-SoundTouch) server** with:
|
||||
- A linked Spotify account (via the management API OAuth flow)
|
||||
- The `GET /mgmt/spotify/token` endpoint (returns `{accessToken, username}`)
|
||||
- Management API credentials (HTTP Basic Auth)
|
||||
|
||||
## Installation
|
||||
SSH into the speaker and run:
|
||||
```bash
|
||||
# 1. Create bin directory
|
||||
mkdir -p /mnt/nv/bin
|
||||
# 2. Create the config file with your service connection info
|
||||
cat > /mnt/nv/BoseApp-Persistence/1/spotify-primer.conf << 'EOF'
|
||||
SOUNDTOUCH_URL=https://soundtouch.example.com
|
||||
SOUNDTOUCH_USER=admin
|
||||
SOUNDTOUCH_PASS=secret
|
||||
EOF
|
||||
chmod 600 /mnt/nv/BoseApp-Persistence/1/spotify-primer.conf
|
||||
# 3. Copy spotify-boot-primer to the speaker
|
||||
# From your local machine:
|
||||
# cat scripts/spotify/spotify-boot-primer | ssh root@SPEAKER_IP "cat > /mnt/nv/bin/spotify-boot-primer"
|
||||
chmod +x /mnt/nv/bin/spotify-boot-primer
|
||||
# 4. Create the boot hook
|
||||
cat > /mnt/nv/rc.local << 'EOF'
|
||||
#!/bin/bash
|
||||
/mnt/nv/bin/spotify-boot-primer &
|
||||
EOF
|
||||
chmod +x /mnt/nv/rc.local
|
||||
# 5. Set up PATH for interactive SSH sessions (optional but convenient)
|
||||
cat > /mnt/nv/.profile << 'EOF'
|
||||
export PATH="/mnt/nv/bin:$PATH"
|
||||
EOF
|
||||
```
|
||||
|
||||
## Testing
|
||||
```bash
|
||||
# Manual test (speaker must be running):
|
||||
/mnt/nv/bin/spotify-boot-primer
|
||||
# Check logs:
|
||||
logread | grep spotify-primer
|
||||
# Full test — reboot the speaker:
|
||||
reboot
|
||||
# Wait ~30s, then SSH back in and check:
|
||||
logread | grep spotify-primer
|
||||
curl -s "http://localhost:8200/zc?action=getInfo" | grep activeUser
|
||||
```
|
||||
|
||||
## Related
|
||||
- [Bose-SoundTouch](https://github.com/gesellix/Bose-SoundTouch) — Comprehensive Go toolkit with migration automation
|
||||
@@ -1,6 +0,0 @@
|
||||
# Spotify Scripts
|
||||
|
||||
This directory contains scripts and configuration files for the Spotify OAuth integration, specifically for priming Bose SoundTouch speakers.
|
||||
|
||||
These files were adapted from the community gist:
|
||||
https://gist.github.com/timvw/84ef8768ff876ef6805012b3eb4015b0
|
||||
@@ -1,318 +0,0 @@
|
||||
# ZeroConf Analysis - Spotify Connect Integration for Bose SoundTouch
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides a comprehensive analysis of the Spotify Connect ZeroConf protocol as implemented by Bose SoundTouch speakers. ZeroConf enables seamless integration between Spotify clients and SoundTouch hardware without requiring manual configuration.
|
||||
|
||||
## What is ZeroConf in This Context?
|
||||
|
||||
ZeroConf (Zero Configuration) in the Bose SoundTouch ecosystem is a **Spotify Connect integration protocol** that allows Spotify clients (mobile apps, desktop applications) to discover and control SoundTouch speakers automatically. The speakers expose an HTTP API on **port 8200** that implements Spotify's official ZeroConf specification.
|
||||
|
||||
## Network Discovery
|
||||
|
||||
### mDNS/Bonjour Advertisement
|
||||
|
||||
SoundTouch speakers advertise themselves on the local network using:
|
||||
- **Service Type**: `_spotify-connect._tcp`
|
||||
- **Port**: 8200
|
||||
- **TXT Record**: `CPath=/zc` (points to the ZeroConf endpoint)
|
||||
|
||||
This allows Spotify applications to automatically discover available speakers without manual configuration.
|
||||
|
||||
### Endpoint Structure
|
||||
|
||||
```
|
||||
http://[SPEAKER_IP]:8200/zc?action=[ACTION]&[PARAMETERS]
|
||||
```
|
||||
|
||||
Example: `http://192.168.1.100:8200/zc?action=getInfo`
|
||||
|
||||
## The getInfo Action
|
||||
|
||||
### Purpose
|
||||
|
||||
The `getInfo` action retrieves comprehensive device information and current status. This is the most commonly used ZeroConf action for:
|
||||
- Device discovery and identification
|
||||
- Checking Spotify authentication status
|
||||
- Retrieving device capabilities
|
||||
- Monitoring multiroom configurations
|
||||
|
||||
### Request Format
|
||||
|
||||
```http
|
||||
GET http://[SPEAKER_IP]:8200/zc?action=getInfo&version=2.10.0
|
||||
```
|
||||
|
||||
The `version` parameter is optional but recommended for compatibility.
|
||||
|
||||
### Response Properties
|
||||
|
||||
#### Mandatory Fields (Present in All Responses)
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `status` | Integer | Operation result code (101 = success) |
|
||||
| `statusString` | String | Human-readable status description |
|
||||
| `spotifyError` | Integer | Last Spotify SDK error code (0 = no error) |
|
||||
| `responseSource` | String | Entity identifier (e.g., "Bose") |
|
||||
|
||||
#### Device Information Fields
|
||||
|
||||
| Property | Required | Type | Description |
|
||||
|----------|----------|------|-------------|
|
||||
| `version` | Yes | String | ZeroConf API version (e.g., "2.10.0") |
|
||||
| `deviceID` | Yes | String | Unique device identifier (MAC-based) |
|
||||
| `publicKey` | Yes | String | Device's public key for secure communication |
|
||||
| `remoteName` | Yes | String | User-friendly device name shown in Spotify |
|
||||
| `deviceType` | No | String | Device category (e.g., "SPEAKER") |
|
||||
| `brandDisplayName` | Yes | String | Brand name displayed in Spotify apps |
|
||||
| `modelDisplayName` | No | String | Model name for user display |
|
||||
| `libraryVersion` | Yes | String | Spotify Connect library version |
|
||||
| `resolverVersion` | Yes | String | DNS resolution version |
|
||||
| `groupStatus` | Yes | String | Multiroom status: "NONE", "GROUP", or "SLAVE" |
|
||||
| `tokenType` | Yes | String | Authentication token type ("accesstoken") |
|
||||
| `clientID` | Yes | String | Spotify client identifier |
|
||||
| `productID` | Yes | Integer | Spotify product identifier |
|
||||
| `scope` | Yes | String | Permission scope (typically "streaming") |
|
||||
| `availability` | Yes | String | Device availability status |
|
||||
|
||||
#### Status Fields
|
||||
|
||||
| Property | Required | Type | Description |
|
||||
|----------|----------|------|-------------|
|
||||
| `activeUser` | No | String | Currently logged-in Spotify username (if any) |
|
||||
|
||||
#### Advanced Fields (Optional)
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `aliases` | Array | Virtual devices for multiroom zones |
|
||||
| `supported_drm_media_formats` | Array | Supported audio formats with DRM capabilities |
|
||||
| `supported_capabilities` | Integer | Bitmasked device capabilities |
|
||||
|
||||
### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 101,
|
||||
"statusString": "OK",
|
||||
"spotifyError": 0,
|
||||
"responseSource": "Bose",
|
||||
"version": "2.10.0",
|
||||
"deviceID": "0007F537F5ED",
|
||||
"deviceType": "SPEAKER",
|
||||
"remoteName": "Living Room Speaker",
|
||||
"publicKey": "BgIwVfz9ZXQG...",
|
||||
"brandDisplayName": "Bose",
|
||||
"modelDisplayName": "SoundTouch 30",
|
||||
"libraryVersion": "master-v3.15.1-g7890abcd",
|
||||
"resolverVersion": "1",
|
||||
"groupStatus": "NONE",
|
||||
"tokenType": "accesstoken",
|
||||
"clientID": "65b708073fc0480ea92a077233ca87bd",
|
||||
"productID": 0,
|
||||
"scope": "streaming",
|
||||
"availability": "",
|
||||
"activeUser": "spotify_username",
|
||||
"supported_drm_media_formats": [
|
||||
{"drm": 0, "formats": 35},
|
||||
{"drm": 1, "formats": 35},
|
||||
{"drm": 3, "formats": 1168}
|
||||
],
|
||||
"supported_capabilities": 1
|
||||
}
|
||||
```
|
||||
|
||||
## Key Properties Analysis
|
||||
|
||||
### Critical Status Indicators
|
||||
|
||||
- **`activeUser`**: Most important field for determining if Spotify is active
|
||||
- Present and non-empty: Spotify is authenticated and ready
|
||||
- Empty or missing: No active Spotify session
|
||||
|
||||
- **`remoteName`**: The display name users see in Spotify Connect device lists
|
||||
- Should be descriptive and user-friendly
|
||||
- Can contain UTF-8 characters and special symbols
|
||||
|
||||
### Device Identification
|
||||
|
||||
- **`deviceID`**: Unique identifier for targeting specific speakers
|
||||
- Typically derived from MAC address
|
||||
- Used for device-specific API calls
|
||||
|
||||
- **`groupStatus`**: Critical for multiroom functionality
|
||||
- `"NONE"`: Standalone device
|
||||
- `"GROUP"`: Multiroom master/coordinator
|
||||
- `"SLAVE"`: Member of a multiroom group
|
||||
|
||||
### Display Properties
|
||||
|
||||
- **`brandDisplayName`** and **`modelDisplayName`**: Shown in Spotify client UIs
|
||||
- Should be marketing-appropriate names
|
||||
- Support UTF-8 for international markets
|
||||
|
||||
## Practical Usage Examples
|
||||
|
||||
### 1. Status Checking
|
||||
|
||||
```bash
|
||||
# Check if Spotify is active
|
||||
curl -s "http://192.168.1.100:8200/zc?action=getInfo" | \
|
||||
grep -o '"activeUser" *: *"[^"]*"' | \
|
||||
sed 's/"activeUser" *: *"//;s/"$//'
|
||||
```
|
||||
|
||||
### 2. Device Discovery
|
||||
|
||||
```bash
|
||||
# Get device name and ID
|
||||
info=$(curl -s "http://192.168.1.100:8200/zc?action=getInfo")
|
||||
device_name=$(echo "$info" | grep -o '"remoteName" *: *"[^"]*"' | sed 's/"remoteName" *: *"//;s/"$//')
|
||||
device_id=$(echo "$info" | grep -o '"deviceID" *: *"[^"]*"' | sed 's/"deviceID" *: *"//;s/"$//')
|
||||
```
|
||||
|
||||
### 3. Multiroom Detection
|
||||
|
||||
```bash
|
||||
# Check multiroom status
|
||||
group_status=$(curl -s "http://192.168.1.100:8200/zc?action=getInfo" | \
|
||||
grep -o '"groupStatus" *: *"[^"]*"' | \
|
||||
sed 's/"groupStatus" *: *"//;s/"$//')
|
||||
```
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
The ZeroConf API supports the `addUser` action for Spotify authentication:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://192.168.1.100:8200/zc" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "action=addUser&userName=${SPOTIFY_USER}&blob=${ACCESS_TOKEN}&clientKey=&tokenType=accesstoken"
|
||||
```
|
||||
|
||||
### Token Requirements
|
||||
|
||||
- **Access Token**: Valid Spotify OAuth access token
|
||||
- **Username**: Spotify username associated with the token
|
||||
- **Token Type**: Always "accesstoken" for current implementations
|
||||
- **Client Key**: Empty string for current protocol version
|
||||
|
||||
### Token Lifecycle
|
||||
|
||||
1. Tokens expire after 1 hour (3600 seconds)
|
||||
2. Speakers must be re-primed after reboot
|
||||
3. Use `getInfo` to verify successful authentication via `activeUser` field
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Communication Security
|
||||
|
||||
- **Protocol**: HTTP (plain text) is standard, HTTPS supported but optional
|
||||
- **Network Scope**: Local network only (port 8200 typically not exposed externally)
|
||||
- **Authentication**: Token-based, no permanent credentials stored
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Token Management**:
|
||||
- Never store long-lived tokens on devices
|
||||
- Implement token refresh mechanisms
|
||||
- Use centralized token servers when possible
|
||||
|
||||
2. **Network Security**:
|
||||
- Ensure port 8200 is not accessible from external networks
|
||||
- Consider HTTPS for enhanced security
|
||||
- Implement proper firewall rules
|
||||
|
||||
3. **Error Handling**:
|
||||
- Always check `status` and `spotifyError` fields
|
||||
- Implement retry mechanisms for network failures
|
||||
- Log authentication failures for debugging
|
||||
|
||||
## Integration Patterns
|
||||
|
||||
### Boot-time Automation
|
||||
|
||||
See `spotify-boot-primer.sh` for a complete example of:
|
||||
1. Waiting for ZeroConf endpoint availability
|
||||
2. Checking current authentication status
|
||||
3. Fetching fresh tokens from a management server
|
||||
4. Automatically priming speakers at startup
|
||||
|
||||
### Manual Priming
|
||||
|
||||
See `spotify-prime-speaker.sh` for standalone token injection:
|
||||
1. Validate access tokens against Spotify API
|
||||
2. Extract username from token metadata
|
||||
3. Prime individual speakers
|
||||
4. Verify successful authentication
|
||||
|
||||
### Monitoring and Health Checks
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Health check script
|
||||
SPEAKER_IP="192.168.1.100"
|
||||
info=$(curl -sf --max-time 5 "http://${SPEAKER_IP}:8200/zc?action=getInfo" 2>/dev/null)
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
active_user=$(echo "$info" | grep -o '"activeUser" *: *"[^"]*"' | sed 's/"activeUser" *: *"//;s/"$//')
|
||||
if [ -n "$active_user" ]; then
|
||||
echo "✅ Spotify active (user: $active_user)"
|
||||
else
|
||||
echo "⚠️ Speaker reachable but Spotify not active"
|
||||
fi
|
||||
else
|
||||
echo "❌ Speaker unreachable"
|
||||
fi
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Port 8200 Unreachable**
|
||||
- Check network connectivity
|
||||
- Verify speaker is powered on
|
||||
- Confirm IP address is correct
|
||||
|
||||
2. **Empty `activeUser` After Authentication**
|
||||
- Wait 2-5 seconds after `addUser` request
|
||||
- Verify access token is valid and not expired
|
||||
- Check `spotifyError` field for SDK errors
|
||||
|
||||
3. **Authentication Failures**
|
||||
- Ensure token has correct scopes
|
||||
- Verify username matches token owner
|
||||
- Check token expiration time
|
||||
|
||||
### Diagnostic Commands
|
||||
|
||||
```bash
|
||||
# Test basic connectivity
|
||||
curl -sf --max-time 5 "http://192.168.1.100:8200/zc?action=getInfo"
|
||||
|
||||
# Check detailed response
|
||||
curl -s "http://192.168.1.100:8200/zc?action=getInfo" | jq .
|
||||
|
||||
# Monitor authentication status
|
||||
while true; do
|
||||
active=$(curl -s "http://192.168.1.100:8200/zc?action=getInfo" | \
|
||||
grep -o '"activeUser" *: *"[^"]*"' | sed 's/"activeUser" *: *"//;s/"$//')
|
||||
echo "$(date): activeUser = '$active'"
|
||||
sleep 10
|
||||
done
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Spotify ZeroConf API Documentation](https://developer.spotify.com/documentation/commercial-hardware/implementation/guides/zeroconf)
|
||||
- [Bose SoundTouch Toolkit](https://github.com/gesellix/Bose-SoundTouch)
|
||||
- Scripts in this directory:
|
||||
- `spotify-boot-primer.sh`: Automated boot-time priming
|
||||
- `spotify-prime-speaker.sh`: Manual speaker priming
|
||||
- `spotify-primer.conf.example`: Configuration template
|
||||
|
||||
---
|
||||
|
||||
*This analysis is based on Spotify's official ZeroConf specification and practical implementation experience with Bose SoundTouch speakers.*
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
# /mnt/nv/rc.local — runs at boot via shelby_local (S97)
|
||||
# Launches Spotify boot primer in background since SoundTouch starts at S99
|
||||
/mnt/nv/bin/spotify-boot-primer &
|
||||
@@ -1,144 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# spotify-boot-primer — Self-contained Spotify primer for Bose SoundTouch speakers
|
||||
#
|
||||
# Runs at boot (via /mnt/nv/rc.local), waits for the ZeroConf endpoint to
|
||||
# come up, fetches a fresh Spotify token from a soundtouch-service server, and
|
||||
# primes the speaker. No Spotify credentials stored on the device.
|
||||
#
|
||||
# Only needs: curl, grep, sed (all available on the speaker via busybox).
|
||||
#
|
||||
# Install:
|
||||
# 1. mkdir -p /mnt/nv/soundtouch-service
|
||||
# 2. Copy this script to /mnt/nv/soundtouch-service/spotify-boot-primer
|
||||
# 3. Create /mnt/nv/soundtouch-service/spotify-primer.conf
|
||||
# 4. Create /mnt/nv/rc.local that backgrounds this script
|
||||
# 5. chmod +x /mnt/nv/rc.local /mnt/nv/soundtouch-service/spotify-boot-primer
|
||||
#
|
||||
# Config file format (/mnt/nv/soundtouch-service/spotify-primer.conf):
|
||||
# SOUNDTOUCH_URL=https://soundtouch.example.com
|
||||
# SOUNDTOUCH_USER=admin
|
||||
# SOUNDTOUCH_PASS=secret
|
||||
#
|
||||
# Related:
|
||||
# https://github.com/gesellix/Bose-SoundTouch
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
CONF="/mnt/nv/soundtouch-service/spotify-primer.conf"
|
||||
LOG_TAG="spotify-primer[$$]"
|
||||
ZC_URL="http://localhost:8200/zc"
|
||||
MAX_WAIT=120 # max seconds to wait for port 8200
|
||||
RETRY_DELAY=3 # seconds between retries
|
||||
|
||||
# --- Logging ---
|
||||
log() {
|
||||
logger -s -t "$LOG_TAG" -p "$1" "$2"
|
||||
}
|
||||
|
||||
# --- JSON parsing without jq ---
|
||||
# Extract a string value: echo '{"key":"val"}' | json_str key
|
||||
json_str() {
|
||||
grep -o "\"$1\" *: *\"[^\"]*\"" | sed "s/\"$1\" *: *\"//;s/\"$//"
|
||||
}
|
||||
|
||||
# Extract a numeric value: echo '{"key":123}' | json_num key
|
||||
json_num() {
|
||||
grep -o "\"$1\" *: *[0-9]*" | sed "s/\"$1\" *: *//"
|
||||
}
|
||||
|
||||
# --- Load config ---
|
||||
if [ ! -f "$CONF" ]; then
|
||||
log err "Config not found: $CONF"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
. "$CONF"
|
||||
|
||||
for var in SOUNDTOUCH_URL SOUNDTOUCH_USER SOUNDTOUCH_PASS; do
|
||||
if [ -z "${!var:-}" ]; then
|
||||
log err "Missing $var in $CONF"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
log info "Config loaded (server=${SOUNDTOUCH_URL})"
|
||||
|
||||
# --- Wait for ZeroConf endpoint (port 8200) ---
|
||||
log info "Waiting for ZeroConf endpoint (max ${MAX_WAIT}s)..."
|
||||
waited=0
|
||||
while true; do
|
||||
if curl -sf --max-time 2 "${ZC_URL}?action=getInfo" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
waited=$((waited + RETRY_DELAY))
|
||||
if [ $waited -ge $MAX_WAIT ]; then
|
||||
log err "ZeroConf endpoint not available after ${MAX_WAIT}s — giving up"
|
||||
exit 1
|
||||
fi
|
||||
sleep $RETRY_DELAY
|
||||
done
|
||||
log info "ZeroConf endpoint is up (waited ${waited}s)"
|
||||
|
||||
# --- Check if already primed ---
|
||||
info=$(curl -sf --max-time 5 "${ZC_URL}?action=getInfo" 2>/dev/null)
|
||||
active_user=$(echo "$info" | json_str activeUser)
|
||||
device_name=$(echo "$info" | json_str remoteName)
|
||||
|
||||
if [ -n "$active_user" ]; then
|
||||
log info "Already primed (device=$device_name, activeUser=$active_user) — nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log info "Speaker '$device_name' has no active Spotify user — priming..."
|
||||
|
||||
# --- Get token from soundtouch-service server ---
|
||||
log info "Requesting Spotify token from soundtouch-service..."
|
||||
token_response=$(curl -sf --max-time 15 \
|
||||
-u "${SOUNDTOUCH_USER}:${SOUNDTOUCH_PASS}" \
|
||||
"${SOUNDTOUCH_URL}/mgmt/spotify/token" \
|
||||
2>&1)
|
||||
|
||||
if [ $? -ne 0 ] || [ -z "$token_response" ]; then
|
||||
log err "Failed to get token from soundtouch-service (is the server reachable?)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
access_token=$(echo "$token_response" | json_str accessToken)
|
||||
user=$(echo "$token_response" | json_str username)
|
||||
|
||||
if [ -z "$access_token" ] || [ -z "$user" ]; then
|
||||
error_msg=$(echo "$token_response" | json_str detail)
|
||||
log err "soundtouch-service returned error: ${error_msg:-no token/username in response}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log info "Got token for user $user (${access_token:0:10}...)"
|
||||
|
||||
# --- Prime the speaker ---
|
||||
result=$(curl -sf --max-time 10 -X POST "$ZC_URL" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "action=addUser&userName=${user}&blob=${access_token}&clientKey=&tokenType=accesstoken" \
|
||||
2>&1)
|
||||
|
||||
status=$(echo "$result" | json_num status)
|
||||
status_str=$(echo "$result" | json_str statusString)
|
||||
|
||||
if [ "$status" != "101" ]; then
|
||||
log err "addUser failed: status=$status ($status_str)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Verify (retry — speaker needs a few seconds after cold boot) ---
|
||||
log info "addUser accepted (status 101) — verifying..."
|
||||
for i in 1 2 3 4 5; do
|
||||
sleep $((i * 2))
|
||||
active_user=$(curl -sf --max-time 5 "${ZC_URL}?action=getInfo" | json_str activeUser)
|
||||
if [ -n "$active_user" ]; then
|
||||
log info "Speaker primed successfully (activeUser=$active_user)"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
log warning "Speaker accepted addUser but activeUser still empty after 30s"
|
||||
exit 1
|
||||
@@ -1,141 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# spotify-prime-speaker — Prime a Bose SoundTouch speaker for Spotify playback
|
||||
#
|
||||
# Activates Spotify on a SoundTouch speaker by sending an access token
|
||||
# via the Spotify Connect ZeroConf endpoint (port 8200). This is the
|
||||
# same mechanism the Spotify desktop app uses internally.
|
||||
#
|
||||
# Works standalone — no soundtouch-service, ueberboese, or other server required.
|
||||
#
|
||||
# Requirements: curl, jq
|
||||
#
|
||||
# Usage:
|
||||
# ./spotify-prime-speaker SPEAKER_IP ACCESS_TOKEN
|
||||
#
|
||||
# Example:
|
||||
# ./spotify-prime-speaker 192.168.1.143 BQDj...your_token...
|
||||
#
|
||||
# How to get an access token:
|
||||
# - Spotify Developer Console: https://developer.spotify.com
|
||||
# (create an app, use the "Get Token" button)
|
||||
# - Via soundtouch-service management API: POST /mgmt/spotify/auth/init
|
||||
# - Via ueberboese management API: POST /mgmt/spotify/init
|
||||
# - Any Spotify OAuth Authorization Code flow with user-read-email scope
|
||||
#
|
||||
# Notes:
|
||||
# - Access tokens expire after 1 hour (3600 seconds)
|
||||
# - The speaker must be on the same network and reachable on port 8200
|
||||
# - After priming, Spotify presets on the speaker should work immediately
|
||||
# - Re-run after each speaker reboot (or use a server like soundtouch-service
|
||||
# to automate this)
|
||||
#
|
||||
# How it works:
|
||||
# The Bose SoundTouch speaker exposes a Spotify Connect ZeroConf API
|
||||
# on port 8200. By sending an addUser request with a valid Spotify
|
||||
# access token, the speaker activates its built-in Spotify Connect
|
||||
# client. No encryption is needed — the token is sent as plain text,
|
||||
# exactly like the Spotify desktop app does it.
|
||||
#
|
||||
# Related:
|
||||
# - https://github.com/gesellix/Bose-SoundTouch (comprehensive toolkit)
|
||||
set -euo pipefail
|
||||
|
||||
# --- Argument parsing ---
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "Usage: $0 SPEAKER_IP ACCESS_TOKEN"
|
||||
echo ""
|
||||
echo "Prime a Bose SoundTouch speaker for Spotify playback."
|
||||
echo ""
|
||||
echo "Arguments:"
|
||||
echo " SPEAKER_IP IP address of the SoundTouch speaker"
|
||||
echo " ACCESS_TOKEN Spotify access token (starts with BQ...)"
|
||||
echo ""
|
||||
echo "Get a token at https://developer.spotify.com or via a server's OAuth flow."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SPEAKER_IP="$1"
|
||||
TOKEN="$2"
|
||||
ZC_URL="http://${SPEAKER_IP}:8200/zc"
|
||||
|
||||
# --- Dependency check ---
|
||||
for cmd in curl jq; do
|
||||
if ! command -v "$cmd" &>/dev/null; then
|
||||
echo "Error: $cmd is required but not installed." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# --- Step 1: Discover Spotify username from token ---
|
||||
echo "Discovering Spotify user from token..."
|
||||
ME_RESPONSE=$(curl -sf -H "Authorization: Bearer ${TOKEN}" \
|
||||
https://api.spotify.com/v1/me 2>&1) || {
|
||||
echo "Error: Failed to call Spotify /me API. Is the token valid?" >&2
|
||||
echo " (tokens expire after 1 hour)" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
USER=$(echo "$ME_RESPONSE" | jq -r '.id // empty')
|
||||
if [ -z "$USER" ]; then
|
||||
echo "Error: Could not extract user ID from Spotify response." >&2
|
||||
echo "$ME_RESPONSE" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " Spotify user: $USER"
|
||||
|
||||
# --- Step 2: Check current speaker status ---
|
||||
echo "Checking speaker at ${SPEAKER_IP}:8200..."
|
||||
INFO=$(curl -sf "${ZC_URL}?action=getInfo" 2>&1) || {
|
||||
echo "Error: Could not reach speaker at ${SPEAKER_IP}:8200." >&2
|
||||
echo " Is the speaker on and on the same network?" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ACTIVE=$(echo "$INFO" | jq -r '.activeUser // empty')
|
||||
DEVICE_NAME=$(echo "$INFO" | jq -r '.remoteName // empty')
|
||||
|
||||
if [ -n "$DEVICE_NAME" ]; then
|
||||
echo " Speaker: $DEVICE_NAME"
|
||||
fi
|
||||
|
||||
if [ -n "$ACTIVE" ]; then
|
||||
echo " Already primed (activeUser=$ACTIVE)"
|
||||
echo "Done — speaker is ready for Spotify playback."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo " No active Spotify user — priming now..."
|
||||
|
||||
# --- Step 3: Send addUser ---
|
||||
RESULT=$(curl -sf -X POST "${ZC_URL}" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "action=addUser&userName=${USER}&blob=${TOKEN}&clientKey=&tokenType=accesstoken" \
|
||||
2>&1) || {
|
||||
echo "Error: addUser request failed." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
STATUS=$(echo "$RESULT" | jq -r '.status // -1')
|
||||
STATUS_STR=$(echo "$RESULT" | jq -r '.statusString // empty')
|
||||
|
||||
if [ "$STATUS" != "101" ]; then
|
||||
echo "Error: Speaker returned status $STATUS ($STATUS_STR)" >&2
|
||||
echo "$RESULT" | jq . >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " Speaker accepted the token (status 101)."
|
||||
|
||||
# --- Step 4: Verify ---
|
||||
echo " Verifying (waiting 2 seconds)..."
|
||||
sleep 2
|
||||
ACTIVE=$(curl -sf "${ZC_URL}?action=getInfo" | jq -r '.activeUser // empty')
|
||||
|
||||
if [ -n "$ACTIVE" ]; then
|
||||
echo "Done — speaker primed for Spotify (activeUser=$ACTIVE)"
|
||||
else
|
||||
echo "Warning: Speaker returned 101 but activeUser is still empty."
|
||||
echo " The speaker may need more time. Try pressing a Spotify preset."
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,6 +0,0 @@
|
||||
# /mnt/nv/BoseApp-Persistence/1/spotify-primer.conf — service connection for boot primer
|
||||
# The speaker fetches a fresh Spotify token from the service at boot.
|
||||
# No Spotify credentials needed on the device.
|
||||
SOUNDTOUCH_URL=https://soundtouch.example.com
|
||||
SOUNDTOUCH_USER=admin
|
||||
SOUNDTOUCH_PASS=secret
|
||||
Reference in New Issue
Block a user