diff --git a/docs/concepts/spotify-priming-strategy.md b/docs/concepts/spotify-priming-strategy.md
index fdcf167..3b3d901 100644
--- a/docs/concepts/spotify-priming-strategy.md
+++ b/docs/concepts/spotify-priming-strategy.md
@@ -19,22 +19,21 @@ We avoid invasive modifications to the speaker's filesystem.
- **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 does not strictly depend on a *periodic* loop. Instead, AfterTouch uses multiple **Liveness Signals** to identify when a speaker needs attention:
+Priming is triggered when the speaker signals it is active and ready, specifically:
-- **Incoming "Pull" Requests:** When the speaker reaches out to AfterTouch endpoints (e.g., `/marge`, `/bmx`, or `/api`), it signals that the device is active. AfterTouch can use this as a trigger to ensure the device's ZeroConf state is correctly primed.
-- **Discovery Events:** Background scans (mDNS/UPnP) or manual refreshes in the UI serve as checkpoints.
-- **Server Startup:** When AfterTouch starts, it can proactively check all known devices from its database.
+- **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 Self-Healing (Default)
-By default, AfterTouch acts as the "Watchdog." It ensures that if a speaker loses its session (due to a crash, power loss, or token expiry), it is automatically re-primed during the next discovery checkpoint.
+### 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 discovery remains decoupled:
+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.
diff --git a/pkg/service/handlers/handlers_marge.go b/pkg/service/handlers/handlers_marge.go
index 05ab16b..5e3ad84 100644
--- a/pkg/service/handlers/handlers_marge.go
+++ b/pkg/service/handlers/handlers_marge.go
@@ -16,11 +16,6 @@ import (
// HandleMargeSourceProviders returns the Marge source providers.
func (s *Server) HandleMargeSourceProviders(w http.ResponseWriter, r *http.Request) {
- // Trigger Spotify priming as this is a common liveness signal
- if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
- go s.PrimeDeviceWithSpotify(host)
- }
-
etag := strconv.FormatInt(time.Now().UnixMilli(), 10)
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
@@ -40,11 +35,6 @@ func (s *Server) HandleMargeSourceProviders(w http.ResponseWriter, r *http.Reque
// HandleMargeAccountFull returns the full Marge account information.
func (s *Server) HandleMargeAccountFull(w http.ResponseWriter, r *http.Request) {
- // Trigger Spotify priming as this is a common liveness signal
- if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
- go s.PrimeDeviceWithSpotify(host)
- }
-
account := chi.URLParam(r, "account")
device := r.URL.Query().Get("device")
@@ -67,7 +57,43 @@ func (s *Server) HandleMargeAccountFull(w http.ResponseWriter, r *http.Request)
}
// HandleMargePowerOn handles the Marge power on request.
-func (s *Server) HandleMargePowerOn(w http.ResponseWriter, _ *http.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)
+ }
+ }
+
w.WriteHeader(http.StatusOK)
}
diff --git a/pkg/service/handlers/handlers_marge_test.go b/pkg/service/handlers/handlers_marge_test.go
index e2d9dcb..184ac5e 100644
--- a/pkg/service/handlers/handlers_marge_test.go
+++ b/pkg/service/handlers/handlers_marge_test.go
@@ -414,16 +414,28 @@ func TestMargePowerOn(t *testing.T) {
ts := httptest.NewServer(r)
defer ts.Close()
- res, err := http.Post(ts.URL+"/marge/streaming/support/power_on", "application/xml", bytes.NewReader([]byte("")))
- if err != nil {
- t.Fatal(err)
- }
+ t.Run("EmptyBody", func(t *testing.T) {
+ res, err := http.Post(ts.URL+"/marge/streaming/support/power_on", "application/xml", bytes.NewReader([]byte("")))
+ 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)
- }
+ t.Run("FullBody", func(t *testing.T) {
+ payload := `I633252770373934200002027.0.6.46330069231P63364828AEExcellent192.168.1.1A81B6A536A98192.168.1.100Wireless`
+ 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)
+ }
+ })
}
func TestMargeAdvancedFeatures(t *testing.T) {
diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go
index 1151386..015f260 100644
--- a/pkg/service/handlers/server.go
+++ b/pkg/service/handlers/server.go
@@ -455,9 +455,6 @@ func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) {
if err := s.ds.SaveDeviceInfo(accountID, deviceID, info); err != nil {
log.Printf("Failed to save device info: %v", err)
}
-
- // Proactively prime with Spotify if a link exists
- go s.PrimeDeviceWithSpotify(d.Host)
}
func (s *Server) mergeOverlappingDevices() {