mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
Closes the AfterTouch-side half of issue #234. After a factory reset the speaker's /sources only lists the always-on local entries (AUX, BLUETOOTH, AIRPLAY, NOTIFICATION, QPLAY, plus a SpotifyConnectUserName placeholder); TUNEIN, LOCAL_INTERNET_RADIO, DEEZER, and linked Spotify accounts are absent until the device receives the <sourcesUpdated/> notification the reporter ran by hand. SyncDeviceData now POSTs that notification as the final step, so users get the visible-source-list recovery for free when they click Data Sync. The other half — re-creating Marge.xml so playback resumes — is already handled by the wizard's pair-account flow: it detects an empty <margeAccountUUID/> in /info and prompts the user to pick a known account or generate a new one. The wizard's pairing UI is deliberately user-driven (the user picks the ID); the notification nudge is purely automatic because there's no choice to make. Implementation routes through the existing client surface rather than reinventing it. setup.notifySpeakerSourcesUpdated delegates to pkg/client.Client.NotifySourcesUpdated — the same path handlers_mgmt.go already uses after music-service account changes (handlers_mgmt.go:304, :637). The wire shape lives in one place (pkg/models.NewSourcesUpdatedNotification). Fire-and-forget: a notification failure logs but doesn't fail the sync. Adjacent UX changes: - docs/guides/TROUBLESHOOTING.md: new section "Presets flash then revert to 'Select a preset' after a factory reset". Names the symptom, the Marge.xml + reduced-/sources cause, and walks the user through re-opening the Migration tab + Data Sync. - pkg/service/handlers/web/js/script.js: devices list now renders a "⚠ Not paired — re-pair" badge in the account-ID column for speakers whose live /info reports an empty margeAccountUUID. Clicking it opens the Migration tab pre-filled with that device, surfacing the wizard's existing "Not paired (factory-reset or never paired)" flow without making users discover it cold. - pkg/service/testing/fakespeaker/testdata/info.xml: demo speaker now reports margeAccountUUID=1234567 instead of the misleading 0000000 (which AfterTouch happens to accept as syntactically valid but is not a documented sentinel anywhere — the convention is empty for factory-reset, a real 7-digit number otherwise, matching pkg/client/testdata/info_response_st{10,20}.xml). Screenshots regenerated accordingly. Test scaffolding: - fakespeaker grows a POST /notification recorder that captures body + Content-Type; tests assert on s.Notifications(). - TestIssue234_FactoryResetSpeakerSyncsReducedSources now drives SyncDeviceData end-to-end (exercises the wiring) and asserts the notification fires with the right deviceID and shape. - TestFakeSpeakerNotificationRecorder pins the recorder contract and the POST-only method gate. Refs #234. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
365 lines
12 KiB
Go
365 lines
12 KiB
Go
// Package fakespeaker runs a minimal HTTP server that impersonates the
|
|
// SoundTouch device's :8090 API surface with sanitized, embedded fixture
|
|
// data. It exists so docs/screenshot tooling and integration setups can
|
|
// register a "speaker" without depending on real hardware or leaking
|
|
// personal data into committed artifacts.
|
|
//
|
|
// The fixture set is deliberately narrow: enough for the soundtouch-service
|
|
// to accept device registration and render initial UI views. Extend the
|
|
// route set as additional pre-flight or migration flows need coverage.
|
|
package fakespeaker
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
//go:embed testdata/info.xml testdata/presets.xml testdata/recents.xml testdata/networkinfo.xml testdata/sources.xml testdata/supportedurls.xml testdata/now_playing.xml
|
|
var fixtures embed.FS
|
|
|
|
// Config configures a fake speaker. The zero value is valid and binds the
|
|
// HTTP API to a random port on 127.0.0.1 with no telnet listener.
|
|
type Config struct {
|
|
// HTTPListen is the bind address for the device's :8090 HTTP API
|
|
// (e.g. "127.0.0.1:8090" or ":8090"). Empty means "127.0.0.1:0" —
|
|
// let the OS pick a port.
|
|
HTTPListen string
|
|
|
|
// TelnetListen is the bind address for the device's :17000
|
|
// diagnostic shell. Empty disables the telnet listener entirely.
|
|
// Use "127.0.0.1:17000" to match the real port the wizard probes.
|
|
TelnetListen string
|
|
|
|
// FixtureOverrides replaces the response body for the given fixture
|
|
// route (e.g. "/info", "/presets", "/sources") with the supplied
|
|
// bytes. Routes not present in the map fall through to the embedded
|
|
// defaults shipped under testdata/. A nil or empty map keeps the
|
|
// default behaviour the screenshot pipeline relies on.
|
|
//
|
|
// Stateful handlers (/getGroup, /addGroup, /updateGroup,
|
|
// /removeGroup) are not affected — overrides only apply to the
|
|
// GET fixture routes. Use this to wire issue-specific payloads
|
|
// into per-issue regression tests; see
|
|
// pkg/service/setup/issue218_regression_test.go for the pattern.
|
|
FixtureOverrides map[string][]byte
|
|
}
|
|
|
|
// Server is a running fake speaker. It bundles whichever sub-servers
|
|
// were enabled in the Config; consult HTTPAddr / TelnetAddr to discover
|
|
// where they actually bound.
|
|
type Server struct {
|
|
srv *http.Server
|
|
httpAddr string
|
|
telnet *telnetServer
|
|
|
|
mu sync.Mutex
|
|
notifications []NotificationCall
|
|
}
|
|
|
|
// NotificationCall records a single POST /notification request the
|
|
// fake received. Tests use it to assert that AfterTouch (or any
|
|
// other component under test) fired the expected speaker-side
|
|
// notification.
|
|
type NotificationCall struct {
|
|
// Body is the request body verbatim.
|
|
Body []byte
|
|
// ContentType is the value of the Content-Type header.
|
|
ContentType string
|
|
}
|
|
|
|
// Notifications returns a snapshot of every POST /notification call
|
|
// the fake has received, in arrival order. The slice is independent
|
|
// of the server's internal state — callers can keep it for assertions
|
|
// without holding a lock.
|
|
func (s *Server) Notifications() []NotificationCall {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
out := make([]NotificationCall, len(s.notifications))
|
|
copy(out, s.notifications)
|
|
|
|
return out
|
|
}
|
|
|
|
// Start binds the configured listeners and serves them in background
|
|
// goroutines. It returns once they are ready (so callers can immediately
|
|
// use the resolved addresses) or with an error if any bind failed.
|
|
func Start(cfg Config) (*Server, error) {
|
|
httpListen := cfg.HTTPListen
|
|
if httpListen == "" {
|
|
httpListen = "127.0.0.1:0"
|
|
}
|
|
|
|
ln, err := net.Listen("tcp", httpListen)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fakespeaker: listen %s: %w", httpListen, err)
|
|
}
|
|
|
|
s := &Server{
|
|
httpAddr: ln.Addr().String(),
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
registerRoutes(mux, cfg.FixtureOverrides, s)
|
|
|
|
s.srv = &http.Server{
|
|
Handler: mux,
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
}
|
|
|
|
go func() {
|
|
_ = s.srv.Serve(ln)
|
|
}()
|
|
|
|
if cfg.TelnetListen != "" {
|
|
ts, terr := startTelnetServer(cfg.TelnetListen)
|
|
if terr != nil {
|
|
_ = s.srv.Close()
|
|
return nil, terr
|
|
}
|
|
|
|
s.telnet = ts
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// HTTPAddr returns the resolved HTTP listen address as "host:port".
|
|
func (s *Server) HTTPAddr() string {
|
|
return s.httpAddr
|
|
}
|
|
|
|
// TelnetAddr returns the resolved telnet listen address as "host:port",
|
|
// or "" if the telnet listener is disabled.
|
|
func (s *Server) TelnetAddr() string {
|
|
if s.telnet == nil {
|
|
return ""
|
|
}
|
|
|
|
return s.telnet.Addr()
|
|
}
|
|
|
|
// Stop shuts all sub-servers down, blocking until in-flight requests
|
|
// finish or ctx is cancelled.
|
|
func (s *Server) Stop(ctx context.Context) error {
|
|
if s.telnet != nil {
|
|
s.telnet.Stop()
|
|
}
|
|
|
|
if err := s.srv.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func registerRoutes(mux *http.ServeMux, overrides map[string][]byte, s *Server) {
|
|
fixture := func(route, embedPath string) {
|
|
mux.HandleFunc(route, serveFixtureOr(embedPath, overrides[route]))
|
|
}
|
|
|
|
fixture("/info", "testdata/info.xml")
|
|
fixture("/presets", "testdata/presets.xml")
|
|
fixture("/recents", "testdata/recents.xml")
|
|
fixture("/networkInfo", "testdata/networkinfo.xml")
|
|
fixture("/sources", "testdata/sources.xml")
|
|
fixture("/supportedURLs", "testdata/supportedurls.xml")
|
|
fixture("/now_playing", "testdata/now_playing.xml")
|
|
|
|
mux.HandleFunc("/getGroup", serveEmptyGroup)
|
|
mux.HandleFunc("/addGroup", handleAddGroup)
|
|
mux.HandleFunc("/updateGroup", handleUpdateGroup)
|
|
mux.HandleFunc("/removeGroup", handleRemoveGroup)
|
|
mux.HandleFunc("/notification", s.handleNotification)
|
|
}
|
|
|
|
// handleNotification records a POST /notification call so tests can
|
|
// assert that AfterTouch fired the expected speaker-side nudge (e.g.
|
|
// the <sourcesUpdated/> notification that recovers the source list
|
|
// after a factory reset, per issue #234). GET returns 405 — real
|
|
// speakers expose /notification as POST-only.
|
|
func (s *Server) handleNotification(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.Header().Set("Allow", "POST")
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
|
|
return
|
|
}
|
|
|
|
body, _ := io.ReadAll(http.MaxBytesReader(w, r.Body, 64*1024))
|
|
|
|
s.mu.Lock()
|
|
s.notifications = append(s.notifications, NotificationCall{
|
|
Body: body,
|
|
ContentType: r.Header.Get("Content-Type"),
|
|
})
|
|
s.mu.Unlock()
|
|
|
|
// Real speakers respond with <status>/notification</status>; the
|
|
// pkg/client.Client.NotifySourcesUpdated path validates that
|
|
// shape, so the fake has to match it too.
|
|
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
|
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" ?>` + "\n<status>/notification</status>\n"))
|
|
}
|
|
|
|
// serveFixtureOr returns a handler that writes override (when non-nil)
|
|
// or the embedded fixture at embedPath (when override is nil). The
|
|
// override is snapshotted at construction so later mutations of the
|
|
// caller's slice don't change the served body.
|
|
func serveFixtureOr(embedPath string, override []byte) http.HandlerFunc {
|
|
if override != nil {
|
|
snapshot := append([]byte(nil), override...)
|
|
|
|
return func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
|
_, _ = w.Write(snapshot)
|
|
}
|
|
}
|
|
|
|
return serveFixture(embedPath)
|
|
}
|
|
|
|
func serveFixture(path string) http.HandlerFunc {
|
|
body, err := fixtures.ReadFile(path)
|
|
if err != nil {
|
|
// Embed failure is a build-time programmer error; surface it
|
|
// loudly the first time the route is hit.
|
|
return func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "fakespeaker: missing fixture "+path+": "+err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
return func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
|
_, _ = w.Write(body)
|
|
}
|
|
}
|
|
|
|
// serveEmptyGroup mirrors a real device's /getGroup response when it is
|
|
// not part of a stereo pair: an empty <group/> element. Tests that want
|
|
// to assert "no group" round-trip semantics can rely on this shape.
|
|
func serveEmptyGroup(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
|
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>` + "\n<group/>\n"))
|
|
}
|
|
|
|
// handleAddGroup echoes the posted <group> XML back with
|
|
// <status>GROUP_OK</status> appended, matching the success path
|
|
// documented for the stereo-pair flow in issue #252 (see also
|
|
// soundtouch-cli/cmd_group.go and pkg/service/handlers/handlers_marge.go).
|
|
// On GET, returns the same empty-group shape as /getGroup so curl
|
|
// smoke-tests don't 405. Anything other than GET/POST gets a 405.
|
|
func handleAddGroup(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
serveEmptyGroup(w, r)
|
|
return
|
|
case http.MethodPost:
|
|
default:
|
|
w.Header().Set("Allow", "GET, POST")
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
|
|
return
|
|
}
|
|
|
|
body, _ := io.ReadAll(http.MaxBytesReader(w, r.Body, 64*1024))
|
|
|
|
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
|
|
|
resp := buildAddGroupResponse(body)
|
|
_, _ = w.Write(resp)
|
|
}
|
|
|
|
// handleUpdateGroup mirrors handleAddGroup's contract: POST a <group>
|
|
// payload, get the same payload back with <status>GROUP_OK</status>
|
|
// appended. Real speakers use this for renames (POST /updateGroup with
|
|
// the changed <name>) and other in-place edits to an existing pair.
|
|
// GET returns the same empty-group shape /getGroup uses; non-GET/POST
|
|
// gets a 405.
|
|
func handleUpdateGroup(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
serveEmptyGroup(w, r)
|
|
return
|
|
case http.MethodPost:
|
|
default:
|
|
w.Header().Set("Allow", "GET, POST")
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
|
|
return
|
|
}
|
|
|
|
body, _ := io.ReadAll(http.MaxBytesReader(w, r.Body, 64*1024))
|
|
|
|
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
|
|
|
resp := buildAddGroupResponse(body)
|
|
_, _ = w.Write(resp)
|
|
}
|
|
|
|
// handleRemoveGroup matches the documented wiki behaviour: GET on the
|
|
// master speaker, no body, returns the now-empty group shape. The real
|
|
// device dissolves the pair on receipt; the fake is stateless so it
|
|
// just always responds as "no group right now".
|
|
func handleRemoveGroup(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
w.Header().Set("Allow", "GET")
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
|
|
return
|
|
}
|
|
|
|
serveEmptyGroup(w, r)
|
|
}
|
|
|
|
// buildAddGroupResponse inserts <status>GROUP_OK</status> before the
|
|
// closing </group> tag of the posted body. If the body is empty or does
|
|
// not contain </group>, it falls back to a minimal canned success
|
|
// response so callers still see a 200 + parseable XML.
|
|
func buildAddGroupResponse(posted []byte) []byte {
|
|
const closeTag = "</group>"
|
|
|
|
const okFragment = " <status>GROUP_OK</status>\n"
|
|
|
|
if len(posted) == 0 {
|
|
return []byte(`<?xml version="1.0" encoding="UTF-8"?>` + "\n<group>\n" + okFragment + closeTag + "\n")
|
|
}
|
|
|
|
idx := indexOfClose(posted, closeTag)
|
|
if idx < 0 {
|
|
return []byte(`<?xml version="1.0" encoding="UTF-8"?>` + "\n<group>\n" + okFragment + closeTag + "\n")
|
|
}
|
|
|
|
out := make([]byte, 0, len(posted)+len(okFragment))
|
|
out = append(out, posted[:idx]...)
|
|
out = append(out, []byte(okFragment)...)
|
|
out = append(out, posted[idx:]...)
|
|
|
|
return out
|
|
}
|
|
|
|
// indexOfClose returns the index of the last occurrence of needle in b,
|
|
// or -1 if not present. We scan from the right because real-world
|
|
// payloads can technically nest <group> blocks (e.g. inside <roles>),
|
|
// even though the documented stereo-pair payload does not.
|
|
func indexOfClose(b []byte, needle string) int {
|
|
if len(needle) == 0 || len(b) < len(needle) {
|
|
return -1
|
|
}
|
|
|
|
for i := len(b) - len(needle); i >= 0; i-- {
|
|
if string(b[i:i+len(needle)]) == needle {
|
|
return i
|
|
}
|
|
}
|
|
|
|
return -1
|
|
}
|