Prime Spotify only on speaker boot/power_on

This commit is contained in:
Tobias Gesellchen
2026-02-22 00:33:15 +01:00
parent e1f3fc36c8
commit 7823b68bdd
4 changed files with 64 additions and 30 deletions
+6 -7
View File
@@ -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.
+37 -11
View File
@@ -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)
}
+21 -9
View File
@@ -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("<powerOn/>")))
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("<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)
}
})
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 := `<?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)
}
})
}
func TestMargeAdvancedFeatures(t *testing.T) {
-3
View File
@@ -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() {