diff --git a/.gitignore b/.gitignore index f1a3d41..00b8805 100644 --- a/.gitignore +++ b/.gitignore @@ -16,12 +16,14 @@ dist/ /soundtouch-cli /soundtouch-service /soundtouch-web +/dummy-speaker /example-mdns /example-upnp /example-unified /mdns-scanner /websocket-demo /main +/screenshots # Environment configuration .env diff --git a/Makefile b/Makefile index 161129d..9c4c038 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build build-cli test test-coverage check fmt vet lint clean dev help +.PHONY: all build build-cli test test-coverage check fmt vet lint clean dev help screenshots # Go parameters GOCMD=go @@ -336,6 +336,10 @@ docker-run-ports: @echo "Running Docker container with port mapping (discovery will be manual)..." docker run --rm -it -p 8000:8000 -v $$(pwd)/data:/app/data soundtouch-service +screenshots: + @echo "Capturing documentation screenshots..." + @bash scripts/screenshots/run.sh + help: @echo "Available targets:" @echo " build - Build the CLI tool, service, and examples" @@ -356,6 +360,7 @@ help: @echo " dev - Build and show CLI help" @echo " dev-service - Build and run service locally" @echo " dev-service-proxy - Build and run service with proxy (PROXY_URL=url required)" + @echo " screenshots - Capture documentation screenshots (headless Chrome via chromedp)" @echo " dev-discover - Build and run device discovery" @echo " dev-info - Build and get device info (HOST=ip required)" @echo " dev-mdns - Build and run mDNS discovery example" diff --git a/cmd/dummy-speaker/main.go b/cmd/dummy-speaker/main.go new file mode 100644 index 0000000..82a75ab --- /dev/null +++ b/cmd/dummy-speaker/main.go @@ -0,0 +1,107 @@ +// Command dummy-speaker runs an HTTP-only fake SoundTouch speaker and +// optionally registers it with a running soundtouch-service so the web UI +// has a device to display. +// +// Intended for documentation screenshots and local UI smoke checks. Do not +// use against a real network — the fixture payload is synthetic and would +// confuse other tooling that expects live device data. +// +// Example: +// +// dummy-speaker --port 8090 --register http://localhost:8000 +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/service/testing/fakespeaker" +) + +func main() { + listen := flag.String("listen", "127.0.0.1:8090", "bind address for the fake speaker's HTTP API") + telnetListen := flag.String("telnet-listen", "127.0.0.1:17000", "bind address for the fake speaker's telnet diagnostic shell (empty to disable)") + register := flag.String("register", "", "service base URL (e.g. http://localhost:8000) to self-register with via POST /setup/devices") + registerAs := flag.String("register-as", "", "address to send to /setup/devices (defaults to --listen)") + + flag.Parse() + + s, err := fakespeaker.Start(fakespeaker.Config{ + HTTPListen: *listen, + TelnetListen: *telnetListen, + }) + if err != nil { + log.Fatalf("start fake speaker: %v", err) + } + + log.Printf("fake speaker HTTP listening on http://%s", s.HTTPAddr()) + + if addr := s.TelnetAddr(); addr != "" { + log.Printf("fake speaker telnet listening on tcp://%s", addr) + } + + if *register != "" { + target := *registerAs + if target == "" { + target = s.HTTPAddr() + } + + if err := registerWithService(*register, target); err != nil { + log.Printf("self-register failed: %v (continuing anyway)", err) + } else { + log.Printf("registered %s with service at %s", target, *register) + } + } + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + + log.Printf("shutting down") + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + if err := s.Stop(ctx); err != nil { + log.Printf("stop: %v", err) + } +} + +func registerWithService(serviceURL, deviceAddr string) error { + body, err := json.Marshal(map[string]string{"ip": deviceAddr}) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, serviceURL+"/setup/devices", bytes.NewReader(body)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 300 { + return fmt.Errorf("service responded %s", resp.Status) + } + + return nil +} diff --git a/docs/images/ui-devices.png b/docs/images/ui-devices.png index a04753e..c156ff5 100644 Binary files a/docs/images/ui-devices.png and b/docs/images/ui-devices.png differ diff --git a/docs/images/ui-migration.png b/docs/images/ui-migration.png index 2e09a3d..5541f3c 100644 Binary files a/docs/images/ui-migration.png and b/docs/images/ui-migration.png differ diff --git a/docs/images/ui-settings.png b/docs/images/ui-settings.png index 10bdfb9..cc9491e 100644 Binary files a/docs/images/ui-settings.png and b/docs/images/ui-settings.png differ diff --git a/docs/images/ui-sync.png b/docs/images/ui-sync.png index 51a2009..a2996b4 100644 Binary files a/docs/images/ui-sync.png and b/docs/images/ui-sync.png differ diff --git a/go.mod b/go.mod index 74d108f..2d7372b 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/gesellix/bose-soundtouch go 1.26.3 require ( + github.com/chromedp/chromedp v0.15.1 github.com/go-chi/chi/v5 v5.2.5 github.com/google/gopacket v1.1.19 github.com/gorilla/websocket v1.5.3 @@ -18,7 +19,13 @@ require ( ) require ( + github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc // indirect + github.com/chromedp/sysutil v1.1.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect golang.org/x/image v0.40.0 // indirect golang.org/x/mod v0.36.0 // indirect diff --git a/go.sum b/go.sum index b97810e..21b5a59 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU= +github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag= +github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ= +github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -5,6 +11,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= @@ -16,9 +30,13 @@ github.com/hashicorp/mdns v1.0.6/go.mod h1:X4+yWh+upFECLOki1doUPaKpgNQII9gy4bUdC github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= @@ -88,6 +106,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/pkg/service/testing/fakespeaker/fakespeaker.go b/pkg/service/testing/fakespeaker/fakespeaker.go new file mode 100644 index 0000000..dbd9a4c --- /dev/null +++ b/pkg/service/testing/fakespeaker/fakespeaker.go @@ -0,0 +1,139 @@ +// 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" + "net" + "net/http" + "time" +) + +//go:embed testdata/info.xml testdata/presets.xml testdata/recents.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 +} + +// 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 +} + +// 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) + } + + mux := http.NewServeMux() + registerRoutes(mux) + + s := &Server{ + srv: &http.Server{ + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + }, + httpAddr: ln.Addr().String(), + } + + 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) { + mux.HandleFunc("/info", serveFixture("testdata/info.xml")) + mux.HandleFunc("/presets", serveFixture("testdata/presets.xml")) + mux.HandleFunc("/recents", serveFixture("testdata/recents.xml")) +} + +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) + } +} diff --git a/pkg/service/testing/fakespeaker/fakespeaker_test.go b/pkg/service/testing/fakespeaker/fakespeaker_test.go new file mode 100644 index 0000000..3ced3ee --- /dev/null +++ b/pkg/service/testing/fakespeaker/fakespeaker_test.go @@ -0,0 +1,64 @@ +package fakespeaker + +import ( + "context" + "encoding/xml" + "io" + "net/http" + "testing" + "time" +) + +func TestFakeSpeakerServesFixtures(t *testing.T) { + s, err := Start(Config{}) + if err != nil { + t.Fatalf("start: %v", err) + } + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + _ = s.Stop(ctx) + }) + + cases := []struct { + path string + root string + }{ + {"/info", "info"}, + {"/presets", "presets"}, + {"/recents", "recents"}, + } + + for _, tc := range cases { + t.Run(tc.path, func(t *testing.T) { + resp, err := http.Get("http://" + s.HTTPAddr() + tc.path) //nolint:noctx + if err != nil { + t.Fatalf("get %s: %v", tc.path, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + + var root struct { + XMLName xml.Name + } + + if err := xml.Unmarshal(body, &root); err != nil { + t.Fatalf("parse XML: %v\n%s", err, body) + } + + if root.XMLName.Local != tc.root { + t.Fatalf("root element = %q, want %q", root.XMLName.Local, tc.root) + } + }) + } +} diff --git a/pkg/service/testing/fakespeaker/telnet.go b/pkg/service/testing/fakespeaker/telnet.go new file mode 100644 index 0000000..aa5f8f8 --- /dev/null +++ b/pkg/service/testing/fakespeaker/telnet.go @@ -0,0 +1,155 @@ +package fakespeaker + +import ( + "bufio" + "errors" + "fmt" + "net" + "strings" + "sync" + "time" +) + +// telnetBanner mimics what a real SoundTouch device emits on connect to +// :17000. The exact wording is not load-bearing for the migration UI — +// only TelnetReachable is — but a non-empty banner matches the production +// shape and gets surfaced in the wizard for diagnostic value. +const telnetBanner = "Welcome to the Bose SoundTouch diagnostic shell\r\n" + +// telnetGetpdoResponse simulates the protobuf-text-like reply to +// `getpdo CurrentSystemConfiguration` for an *unmigrated* speaker — every +// URL still points at the Bose cloud. This is the happy path for a +// documentation screenshot: the wizard renders as "Not Migrated", lists +// the original URLs, and offers the migration plan. +// +// The shape matches what preflight_crosscheck.parseGetpdoConfig expects: +// " {\n text: \"\"\n}". +const telnetGetpdoResponse = `margeServerUrl { + text: "https://streaming.bose.com" +} +statsServerUrl { + text: "https://stats.bose.com" +} +swUpdateUrl { + text: "https://worldwide.bose.com/updates/soundtouch" +} +bmxRegistryUrl { + text: "https://bmxservice.bose.com/bmx/registry/v1/services" +} +->OK +` + +// telnetServer is a minimal TCP server that satisfies the read-only pre-flight +// probe in pkg/service/setup/telnet_preflight.go. It handles only the commands +// the wizard actually issues and answers every other line with a stub. +type telnetServer struct { + ln net.Listener + addr string + wg sync.WaitGroup + once sync.Once + done chan struct{} +} + +func startTelnetServer(listen string) (*telnetServer, error) { + ln, err := net.Listen("tcp", listen) + if err != nil { + return nil, fmt.Errorf("fakespeaker telnet: listen %s: %w", listen, err) + } + + s := &telnetServer{ + ln: ln, + addr: ln.Addr().String(), + done: make(chan struct{}), + } + + s.wg.Add(1) + + go s.accept() + + return s, nil +} + +func (s *telnetServer) Addr() string { + return s.addr +} + +func (s *telnetServer) Stop() { + s.once.Do(func() { + close(s.done) + _ = s.ln.Close() + }) + s.wg.Wait() +} + +func (s *telnetServer) accept() { + defer s.wg.Done() + + for { + conn, err := s.ln.Accept() + if err != nil { + // Listener closed → graceful shutdown; any other error means + // the OS gave up on us and we should also stop. + if errors.Is(err, net.ErrClosed) { + return + } + + select { + case <-s.done: + return + default: + } + + continue + } + + s.wg.Add(1) + + go s.handle(conn) + } +} + +func (s *telnetServer) handle(conn net.Conn) { + defer s.wg.Done() + defer func() { _ = conn.Close() }() + + // Banner on connect — clients read it via Probe() before any command. + _ = conn.SetWriteDeadline(time.Now().Add(2 * time.Second)) + + if _, err := conn.Write([]byte(telnetBanner)); err != nil { + return + } + + reader := bufio.NewReader(conn) + + for { + // No idle deadline — let the client drive the cadence. The client + // closes the socket after it has its answer (~600 ms idle window), + // which surfaces here as io.EOF and ends the loop. + line, err := reader.ReadString('\n') + if err != nil { + return + } + + resp := respondTo(strings.TrimRight(line, "\r\n")) + _ = conn.SetWriteDeadline(time.Now().Add(2 * time.Second)) + + if _, werr := conn.Write([]byte(resp)); werr != nil { + return + } + } +} + +func respondTo(cmd string) string { + cmd = strings.TrimSpace(cmd) + + switch cmd { + case "getpdo CurrentSystemConfiguration": + return telnetGetpdoResponse + case "": + return "->OK\r\n" + default: + // Unrecognized commands get a benign acknowledgement so the + // probe loop never hangs waiting for a response. + return "->OK\r\n" + } +} diff --git a/pkg/service/testing/fakespeaker/telnet_test.go b/pkg/service/testing/fakespeaker/telnet_test.go new file mode 100644 index 0000000..7d0d559 --- /dev/null +++ b/pkg/service/testing/fakespeaker/telnet_test.go @@ -0,0 +1,81 @@ +package fakespeaker + +import ( + "bufio" + "context" + "net" + "strings" + "testing" + "time" +) + +func TestTelnetServerBannerAndGetpdo(t *testing.T) { + s, err := Start(Config{TelnetListen: "127.0.0.1:0"}) + if err != nil { + t.Fatalf("start: %v", err) + } + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + _ = s.Stop(ctx) + }) + + if s.TelnetAddr() == "" { + t.Fatalf("telnet listener not started") + } + + conn, err := net.DialTimeout("tcp", s.TelnetAddr(), 2*time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + + defer func() { _ = conn.Close() }() + + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + + reader := bufio.NewReader(conn) + + banner, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("read banner: %v", err) + } + + if !strings.Contains(banner, "Bose SoundTouch") { + t.Errorf("banner = %q, want substring %q", banner, "Bose SoundTouch") + } + + if _, err := conn.Write([]byte("getpdo CurrentSystemConfiguration\r\n")); err != nil { + t.Fatalf("write: %v", err) + } + + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + + buf := make([]byte, 1024) + + var got strings.Builder + + for { + n, err := conn.Read(buf) + if n > 0 { + got.Write(buf[:n]) + } + + if err != nil { + break + } + + if strings.Contains(got.String(), "->OK") { + break + } + } + + out := got.String() + + for _, want := range []string{"margeServerUrl", "streaming.bose.com", "swUpdateUrl"} { + if !strings.Contains(out, want) { + t.Errorf("response missing %q\nfull response:\n%s", want, out) + } + } +} diff --git a/pkg/service/testing/fakespeaker/testdata/info.xml b/pkg/service/testing/fakespeaker/testdata/info.xml new file mode 100644 index 0000000..f2595d6 --- /dev/null +++ b/pkg/service/testing/fakespeaker/testdata/info.xml @@ -0,0 +1,32 @@ + + + Demo SoundTouch + SoundTouch 10 + 0000000 + + + SCM + 27.0.6.46330.5043500 epdbuild.trunk.demo.2026-01-01T00:00:00 + SN0000000000000000DEMO + + + PackagedProduct + 27.0.6.46330.5043500 epdbuild.trunk.demo.2026-01-01T00:00:00 + 000000P00000000DEMO + + + https://streaming.bose.com + + 02:00:00:00:00:01 + 127.0.0.1 + + + 02:00:00:00:00:01 + 127.0.0.1 + + sm2 + rhino + normal + GB + GB + diff --git a/pkg/service/testing/fakespeaker/testdata/presets.xml b/pkg/service/testing/fakespeaker/testdata/presets.xml new file mode 100644 index 0000000..e11f832 --- /dev/null +++ b/pkg/service/testing/fakespeaker/testdata/presets.xml @@ -0,0 +1,15 @@ + + + + + Demo Radio + https://example.invalid/preset1.jpg + + + + + Demo News + https://example.invalid/preset2.jpg + + + diff --git a/pkg/service/testing/fakespeaker/testdata/recents.xml b/pkg/service/testing/fakespeaker/testdata/recents.xml new file mode 100644 index 0000000..ff9257f --- /dev/null +++ b/pkg/service/testing/fakespeaker/testdata/recents.xml @@ -0,0 +1,2 @@ + + diff --git a/scripts/screenshots/main.go b/scripts/screenshots/main.go new file mode 100644 index 0000000..c44bab4 --- /dev/null +++ b/scripts/screenshots/main.go @@ -0,0 +1,194 @@ +// Command screenshots drives a headless Chrome via chromedp to capture +// PNG screenshots of the soundtouch-service web UI for documentation. +// +// It is deliberately decoupled from any speaker/service setup: callers +// are responsible for having the service reachable at --base and any +// required devices already registered. See cmd/dummy-speaker for a +// matching no-hardware backend. +// +// Manifest format (JSON): +// +// { +// "shots": [ +// { +// "name": "ui-settings", +// "path": "/web/", +// "click_selector": "button[onclick*=\"tab-settings\"]", +// "wait_selector": "#tab-settings.active", +// "viewport": {"width": 1280, "height": 900}, +// "settle_ms": 250 +// } +// ] +// } +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "path/filepath" + "time" + + "github.com/chromedp/chromedp" +) + +type viewport struct { + Width int `json:"width"` + Height int `json:"height"` + Scale float64 `json:"scale"` +} + +type shot struct { + Name string `json:"name"` + Path string `json:"path"` + ClickSelector string `json:"click_selector,omitempty"` + WaitSelector string `json:"wait_selector,omitempty"` + Evaluate string `json:"evaluate,omitempty"` // JS to run after the click (e.g. to programmatically select a device + trigger summary) + WaitAfterEval string `json:"wait_after_eval,omitempty"` // selector to wait for once the JS evaluation has completed + Viewport viewport `json:"viewport,omitempty"` + SettleMs int `json:"settle_ms,omitempty"` +} + +type manifest struct { + Shots []shot `json:"shots"` +} + +func main() { + base := flag.String("base", "http://localhost:8000", "service base URL") + manifestPath := flag.String("manifest", "scripts/screenshots/manifest.json", "path to shot manifest JSON") + outDir := flag.String("out", "docs/images", "output directory for PNGs") + timeoutSec := flag.Int("timeout", 30, "per-shot timeout (seconds)") + flag.Parse() + + m, err := readManifest(*manifestPath) + if err != nil { + log.Fatalf("read manifest: %v", err) + } + + if err := os.MkdirAll(*outDir, 0o755); err != nil { + log.Fatalf("mkdir %s: %v", *outDir, err) + } + + allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), + append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.Flag("headless", true), + chromedp.Flag("disable-gpu", true), + chromedp.Flag("hide-scrollbars", true), + )...) + defer cancelAlloc() + + browserCtx, cancelBrowser := chromedp.NewContext(allocCtx) + defer cancelBrowser() + + if err := chromedp.Run(browserCtx); err != nil { + log.Fatalf("launch browser: %v", err) + } + + failed := 0 + + for _, sh := range m.Shots { + log.Printf("capturing %s", sh.Name) + + if err := capture(browserCtx, *base, *outDir, sh, time.Duration(*timeoutSec)*time.Second); err != nil { + log.Printf(" failed: %v", err) + + failed++ + + continue + } + + log.Printf(" ok") + } + + if failed > 0 { + log.Fatalf("%d shot(s) failed", failed) + } +} + +func readManifest(path string) (*manifest, error) { + raw, err := os.ReadFile(path) //nolint:gosec + if err != nil { + return nil, err + } + + var m manifest + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + + return &m, nil +} + +func capture(parent context.Context, baseURL, outDir string, sh shot, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + + w, h := sh.Viewport.Width, sh.Viewport.Height + if w == 0 { + w = 1280 + } + + if h == 0 { + h = 900 + } + + scale := sh.Viewport.Scale + if scale == 0 { + scale = 2 // retina-equivalent DPR; sharper text in captured PNGs + } + + settle := time.Duration(sh.SettleMs) * time.Millisecond + if settle == 0 { + settle = 200 * time.Millisecond + } + + tabCtx, tabCancel := chromedp.NewContext(ctx) + defer tabCancel() + + url := baseURL + sh.Path + + actions := []chromedp.Action{ + chromedp.EmulateViewport(int64(w), int64(h), chromedp.EmulateScale(scale)), + chromedp.Navigate(url), + chromedp.WaitReady("body", chromedp.ByQuery), + } + + if sh.ClickSelector != "" { + actions = append(actions, + chromedp.WaitVisible(sh.ClickSelector, chromedp.ByQuery), + chromedp.Click(sh.ClickSelector, chromedp.ByQuery), + ) + } + + if sh.WaitSelector != "" { + actions = append(actions, chromedp.WaitVisible(sh.WaitSelector, chromedp.ByQuery)) + } + + if sh.Evaluate != "" { + actions = append(actions, chromedp.Evaluate(sh.Evaluate, nil)) + } + + if sh.WaitAfterEval != "" { + actions = append(actions, chromedp.WaitVisible(sh.WaitAfterEval, chromedp.ByQuery)) + } + + actions = append(actions, chromedp.Sleep(settle)) + + var buf []byte + + actions = append(actions, chromedp.FullScreenshot(&buf, 100)) + + if err := chromedp.Run(tabCtx, actions...); err != nil { + return fmt.Errorf("chromedp: %w", err) + } + + outPath := filepath.Join(outDir, sh.Name+".png") + if err := os.WriteFile(outPath, buf, 0o644); err != nil { //nolint:gosec + return fmt.Errorf("write %s: %w", outPath, err) + } + + return nil +} diff --git a/scripts/screenshots/manifest.json b/scripts/screenshots/manifest.json new file mode 100644 index 0000000..023e274 --- /dev/null +++ b/scripts/screenshots/manifest.json @@ -0,0 +1,34 @@ +{ + "shots": [ + { + "name": "ui-settings", + "path": "/", + "click_selector": "button[onclick*=\"tab-settings\"]", + "wait_selector": "#tab-settings.active", + "settle_ms": 300 + }, + { + "name": "ui-devices", + "path": "/", + "click_selector": "button[onclick*=\"tab-devices\"]", + "wait_selector": "#tab-devices.active", + "settle_ms": 500 + }, + { + "name": "ui-sync", + "path": "/", + "click_selector": "button[onclick*=\"tab-sync\"]", + "wait_selector": "#tab-sync.active", + "settle_ms": 300 + }, + { + "name": "ui-migration", + "path": "/", + "click_selector": "button[onclick*=\"tab-migration\"]", + "wait_selector": "#tab-migration.active", + "evaluate": "prepareMigration('DEADBEEFCAFE')", + "wait_after_eval": "#migration-summary", + "settle_ms": 4000 + } + ] +} diff --git a/scripts/screenshots/run.sh b/scripts/screenshots/run.sh new file mode 100755 index 0000000..adc0c65 --- /dev/null +++ b/scripts/screenshots/run.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Orchestrates an end-to-end screenshot capture: spins up a clean +# soundtouch-service + dummy-speaker, drives the web UI in headless +# Chrome via the chromedp runner, then tears everything down. +# +# Outputs to docs/images/ by default. Override with OUT_DIR=/some/path. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +OUT_DIR="${OUT_DIR:-docs/images}" +SERVICE_PORT="${SERVICE_PORT:-8000}" +SPEAKER_PORT="${SPEAKER_PORT:-8090}" +DATA_DIR="$(mktemp -d -t soundtouch-screenshots-XXXXXX)" +LOG_DIR="$(mktemp -d -t soundtouch-screenshot-logs-XXXXXX)" + +SERVICE_PID="" +SPEAKER_PID="" + +cleanup() { + set +e + if [ -n "$SPEAKER_PID" ] && kill -0 "$SPEAKER_PID" 2>/dev/null; then + kill "$SPEAKER_PID" + wait "$SPEAKER_PID" 2>/dev/null + fi + if [ -n "$SERVICE_PID" ] && kill -0 "$SERVICE_PID" 2>/dev/null; then + kill "$SERVICE_PID" + wait "$SERVICE_PID" 2>/dev/null + fi + rm -rf "$DATA_DIR" + echo "logs retained at $LOG_DIR" +} +trap cleanup EXIT + +echo "==> building binaries" +go build -o "$LOG_DIR/soundtouch-service" ./cmd/soundtouch-service +go build -o "$LOG_DIR/dummy-speaker" ./cmd/dummy-speaker +go build -o "$LOG_DIR/screenshots" ./scripts/screenshots + +echo "==> seeding settings.json (generic hostname + discovery off to avoid leaking real network info)" +cat > "$DATA_DIR/settings.json" <<'EOF' +{ + "server_url": "http://aftertouch.local:8000", + "https_server_url": "https://aftertouch.local:8443", + "discovery_enabled": false, + "discovery_interval": "1h" +} +EOF + +echo "==> starting soundtouch-service on :$SERVICE_PORT (data: $DATA_DIR)" +"$LOG_DIR/soundtouch-service" --port "$SERVICE_PORT" --data-dir "$DATA_DIR" \ + > "$LOG_DIR/service.log" 2>&1 & +SERVICE_PID=$! + +echo "==> waiting for service to be ready" +for i in $(seq 1 30); do + if curl -fsS "http://127.0.0.1:$SERVICE_PORT/setup/devices" > /dev/null 2>&1; then + break + fi + if ! kill -0 "$SERVICE_PID" 2>/dev/null; then + echo "service died early; log tail:" + tail -40 "$LOG_DIR/service.log" + exit 1 + fi + sleep 0.5 +done + +echo "==> starting dummy-speaker on :$SPEAKER_PORT (registering with service)" +# Register as bare IP (no port) so the service appends :8090 for HTTP and +# :17000 for telnet exactly the way it does with real hardware. This is +# also why the listeners below bind to the canonical Bose ports. +"$LOG_DIR/dummy-speaker" \ + --listen "127.0.0.1:$SPEAKER_PORT" \ + --telnet-listen "127.0.0.1:17000" \ + --register "http://127.0.0.1:$SERVICE_PORT" \ + --register-as "127.0.0.1" \ + > "$LOG_DIR/speaker.log" 2>&1 & +SPEAKER_PID=$! +sleep 1 + +if ! kill -0 "$SPEAKER_PID" 2>/dev/null; then + echo "dummy-speaker died early; log tail:" + tail -40 "$LOG_DIR/speaker.log" + exit 1 +fi + +echo "==> capturing screenshots into $OUT_DIR" +"$LOG_DIR/screenshots" \ + --base "http://127.0.0.1:$SERVICE_PORT" \ + --manifest scripts/screenshots/manifest.json \ + --out "$OUT_DIR" + +echo "==> done"