From 0748e4042c4de6095b9117e5111812e3c04af7bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Lipinsk=C3=BD?= <6032558+Mr-Tao@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:51:16 +0200 Subject: [PATCH] feat(client): add capability-driven device settings --- cmd/soundtouch-cli/cmd_setup.go | 2 +- .../analysis/SETUP-WEBSOCKET-EXPERIMENT.md | 4 + docs/content/docs/guides/CLI-REFERENCE.md | 2 +- .../docs/reference/DEVICE-PAIRING-FLOW.md | 2 +- pkg/client/client.go | 273 ++++++++++++- pkg/client/system_settings_test.go | 368 ++++++++++++++++++ pkg/models/clockdisplay.go | 9 + pkg/models/clockdisplay_test.go | 51 +++ pkg/models/nowplaying.go | 58 +-- pkg/models/nowplaying_test.go | 29 ++ pkg/models/systemsettings.go | 316 +++++++++++++++ pkg/models/systemsettings_test.go | 207 ++++++++++ pkg/service/setup/init_plan.go | 7 +- pkg/service/setup/init_plan_test.go | 20 +- pkg/service/setup/setup_session.go | 14 +- pkg/service/setup/setup_session_test.go | 14 + 16 files changed, 1341 insertions(+), 35 deletions(-) create mode 100644 pkg/client/system_settings_test.go create mode 100644 pkg/models/systemsettings.go create mode 100644 pkg/models/systemsettings_test.go diff --git a/cmd/soundtouch-cli/cmd_setup.go b/cmd/soundtouch-cli/cmd_setup.go index f835c5c1..de00603a 100644 --- a/cmd/soundtouch-cli/cmd_setup.go +++ b/cmd/soundtouch-cli/cmd_setup.go @@ -1979,7 +1979,7 @@ func setupPairCmd() *cli.Command { &cli.StringFlag{Name: "mode", Value: "full", Usage: "full (state machine) or bare (setMargeAccount only — experimental)"}, &cli.StringFlag{Name: "service-url", Value: "http://aftertouch.local:8000", Usage: "AfterTouch base URL (also populates / in setMargeAccount)"}, &cli.StringFlag{Name: "name", Usage: "Speaker name to set during pairing (empty = keep current)"}, - &cli.IntFlag{Name: "language", Value: setup.LanguageEnglish, Usage: "sysLanguage code (2 = English)"}, + &cli.IntFlag{Name: "language", Value: setup.LanguageEnglish, Usage: "sysLanguage code (3 = English)"}, &cli.DurationFlag{Name: "step-timeout", Value: 8 * time.Second}, &cli.StringFlag{Name: "token", Usage: "userAuthToken value (empty = use built-in placeholder matching the Bose app token shape)"}, }, diff --git a/docs/content/docs/analysis/SETUP-WEBSOCKET-EXPERIMENT.md b/docs/content/docs/analysis/SETUP-WEBSOCKET-EXPERIMENT.md index 2bb3fb18..b9d9e356 100644 --- a/docs/content/docs/analysis/SETUP-WEBSOCKET-EXPERIMENT.md +++ b/docs/content/docs/analysis/SETUP-WEBSOCKET-EXPERIMENT.md @@ -120,6 +120,10 @@ This drives `setup.Manager.ExecuteInitPlan` with `SkipURLRewrite=true`, which ru > `SOUNDTOUCH_NOT_CONFIGURED`, so the preflight passes through unchanged; > see `docs/content/docs/reference/DEVICE-PAIRING-FLOW.md`. +The historical capture below sent language code `2`. Stockholm's language +table identifies that code as German; current English-language setup uses code +`3`. The original wire value is retained here as experiment evidence. + ``` SETUP_START SETUP_IDENTIFY_DEVICE_ENTER diff --git a/docs/content/docs/guides/CLI-REFERENCE.md b/docs/content/docs/guides/CLI-REFERENCE.md index d5944059..b5b7b508 100644 --- a/docs/content/docs/guides/CLI-REFERENCE.md +++ b/docs/content/docs/guides/CLI-REFERENCE.md @@ -1465,7 +1465,7 @@ soundtouch-cli --host setup pair --mode=bare --account=1111111 --servic ``` `--account` empty generates a fresh 7-digit ID. `--name` sets the speaker -name during pairing (empty keeps current). `--language` defaults to `2` +name during pairing (empty keeps current). `--language` defaults to `3` (English). `--token` defaults to a built-in placeholder matching the Bose app's token shape. diff --git a/docs/content/docs/reference/DEVICE-PAIRING-FLOW.md b/docs/content/docs/reference/DEVICE-PAIRING-FLOW.md index 7bb7c1e7..43146f0a 100644 --- a/docs/content/docs/reference/DEVICE-PAIRING-FLOW.md +++ b/docs/content/docs/reference/DEVICE-PAIRING-FLOW.md @@ -136,7 +136,7 @@ The pairing flow uses a setup state machine on the device. States must be sent i - +
3
diff --git a/pkg/client/client.go b/pkg/client/client.go index e27b7993..58f26007 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -170,6 +170,11 @@ type Client struct { avTransportURLOverride string } +// ErrMutationOutcomeUnknown reports that a state-changing GET may have reached +// the speaker, but its authoritative response could not be verified. Callers +// must read device state back rather than retrying the mutation blindly. +var ErrMutationOutcomeUnknown = errors.New("state-changing GET outcome is unknown") + // Config holds configuration for the SoundTouch client type Config struct { Host string @@ -1022,6 +1027,152 @@ func (c *Client) SetClockTimeNow() error { return c.SetClockTime(request) } +// GetSystemTimeout retrieves the power-saving setting from /systemtimeout. +func (c *Client) GetSystemTimeout() (*models.SystemTimeout, error) { + var setting models.SystemTimeout + if err := c.get("/systemtimeout", &setting); err != nil { + return nil, fmt.Errorf("failed to get system timeout: %w", err) + } + + return &setting, nil +} + +// SetSystemTimeout updates /systemtimeout. HTTP success only confirms that the +// request was accepted; callers must read the setting back before reporting it. +func (c *Client) SetSystemTimeout(setting *models.SystemTimeout) error { + if err := setting.Validate(); err != nil { + return fmt.Errorf("invalid system timeout request: %w", err) + } + + if err := c.post("/systemtimeout", setting); err != nil { + return fmt.Errorf("failed to set system timeout: %w", err) + } + + return nil +} + +// GetRebroadcastLatencyMode retrieves /rebroadcastlatencymode. +func (c *Client) GetRebroadcastLatencyMode() (*models.RebroadcastLatencyMode, error) { + var setting models.RebroadcastLatencyMode + if err := c.get("/rebroadcastlatencymode", &setting); err != nil { + return nil, fmt.Errorf("failed to get rebroadcast latency mode: %w", err) + } + + return &setting, nil +} + +// SetRebroadcastLatencyMode updates /rebroadcastlatencymode. HTTP success only +// confirms request acceptance; callers must read the setting back. +func (c *Client) SetRebroadcastLatencyMode(mode models.RebroadcastLatencyModeValue) error { + request := &models.RebroadcastLatencyModeRequest{Mode: mode} + if err := request.Validate(); err != nil { + return fmt.Errorf("invalid rebroadcast latency mode request: %w", err) + } + + if err := c.post("/rebroadcastlatencymode", request); err != nil { + return fmt.Errorf("failed to set rebroadcast latency mode: %w", err) + } + + return nil +} + +// GetLanguage retrieves the current integer system language from /language. +// Unknown codes are returned unchanged for compatibility with newer firmware. +func (c *Client) GetLanguage() (*models.SystemLanguage, error) { + var language models.SystemLanguage + if err := c.get("/language", &language); err != nil { + return nil, fmt.Errorf("failed to get system language: %w", err) + } + + return &language, nil +} + +// SetLanguage updates /language. HTTP success only confirms request acceptance; +// callers must read the language back before reporting the change. +func (c *Client) SetLanguage(code models.LanguageCode) error { + request := &models.SystemLanguage{Code: code} + if err := request.Validate(); err != nil { + return fmt.Errorf("invalid system language request: %w", err) + } + + if err := c.post("/language", request); err != nil { + return fmt.Errorf("failed to set system language: %w", err) + } + + return nil +} + +// GetBluetoothInfo retrieves the speaker adapter information from /bluetoothInfo. +func (c *Client) GetBluetoothInfo() (*models.BluetoothInfo, error) { + var info models.BluetoothInfo + if err := c.get("/bluetoothInfo", &info); err != nil { + return nil, fmt.Errorf("failed to get Bluetooth info: %w", err) + } + + return &info, nil +} + +// RenameSource updates the source display name through /nameSource. HTTP +// success only confirms request acceptance; callers must read sources back. +func (c *Client) RenameSource(source, sourceAccount, itemName string) error { + request := &models.SourceRenameRequest{ + Source: source, + SourceAccount: sourceAccount, + ItemName: itemName, + } + if err := request.Validate(); err != nil { + return fmt.Errorf("invalid source rename request: %w", err) + } + + if err := c.post("/nameSource", request); err != nil { + return fmt.Errorf("failed to rename source: %w", err) + } + + return nil +} + +// EnterPairingMode requests the firmware's legacy general pairing mode through +// its state-changing GET endpoint. +func (c *Client) EnterPairingMode() error { + if err := c.mutatingGetConfirmStatus("/enterPairingMode"); err != nil { + return fmt.Errorf("failed to enter pairing mode: %w", err) + } + + return nil +} + +// EnterBluetoothPairing requests Bluetooth discoverable mode through the +// Bluetooth-specific state-changing GET endpoint. Callers must verify +// discoverability through a subsequent now-playing read. +func (c *Client) EnterBluetoothPairing() error { + if err := c.mutatingGetConfirmStatus("/enterBluetoothPairing"); err != nil { + return fmt.Errorf("failed to enter Bluetooth pairing mode: %w", err) + } + + return nil +} + +// ClearPairedList requests the firmware's legacy general paired-list clearing +// through its state-changing GET endpoint. +func (c *Client) ClearPairedList() error { + if err := c.mutatingGetConfirmStatus("/clearPairedList"); err != nil { + return fmt.Errorf("failed to clear paired list: %w", err) + } + + return nil +} + +// ClearBluetoothPaired requests removal of Bluetooth pairings through the +// Bluetooth-specific state-changing GET endpoint. The firmware exposes no +// paired-list readback, so HTTP success alone does not verify physical state. +func (c *Client) ClearBluetoothPaired() error { + if err := c.mutatingGetConfirmStatus("/clearBluetoothPaired"); err != nil { + return fmt.Errorf("failed to clear Bluetooth paired devices: %w", err) + } + + return nil +} + // GetClockDisplay retrieves clock display settings from the /clockDisplay endpoint func (c *Client) GetClockDisplay() (*models.ClockDisplay, error) { var clockDisplay models.ClockDisplay @@ -1157,9 +1308,9 @@ func (c *Client) getWithHTTPClient(httpClient *http.Client, endpoint string, res } // mutatingGet performs a firmware-required state-changing GET exactly once at -// the HTTP transport layer. A fresh connection prevents net/http from -// automatically replaying the request after an ambiguous failure on a reused -// connection. +// the HTTP transport layer, unmarshaling the response into result. A fresh +// connection prevents net/http from automatically replaying the request +// after an ambiguous failure on a reused connection. func (c *Client) mutatingGet(endpoint string, result interface{}) error { baseTransport := c.httpClient.Transport if baseTransport == nil { @@ -1187,6 +1338,122 @@ func (c *Client) mutatingGet(endpoint string, result interface{}) error { return c.getWithHTTPClient(oneShotClient, endpoint, result) } +// mutatingGetConfirmStatus performs the same one-shot, firmware-required +// state-changing GET as mutatingGet, for endpoints that return no meaningful +// body to unmarshal -- confirmation instead comes from the device echoing +// back {endpoint}. +func (c *Client) mutatingGetConfirmStatus(endpoint string) error { + baseTransport := c.httpClient.Transport + if baseTransport == nil { + baseTransport = http.DefaultTransport + } + + transport, ok := baseTransport.(*http.Transport) + if !ok { + return errors.New("state-changing GET requires a cloneable HTTP transport") + } + + oneShotTransport := transport.Clone() + + oneShotTransport.DisableKeepAlives = true + defer oneShotTransport.CloseIdleConnections() + + oneShotClient := &http.Client{ + Transport: oneShotTransport, + Timeout: c.httpClient.Timeout, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + req, err := http.NewRequest(http.MethodGet, c.baseURL+endpoint, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", c.userAgent) + req.Header.Set("Accept", "application/xml") + + resp, err := oneShotClient.Do(req) + if err != nil { + return fmt.Errorf("%w: failed to execute request: %w", ErrMutationOutcomeUnknown, err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("%w: failed to read response: %w", ErrMutationOutcomeUnknown, err) + } + + if resp.StatusCode != http.StatusOK { + if apiErr := mutationAPIError(body); apiErr != nil { + return apiErr + } + + return fmt.Errorf( + "%w: API request failed with status %d: %s", + ErrMutationOutcomeUnknown, + resp.StatusCode, + string(body), + ) + } + + if apiErr := mutationAPIError(body); apiErr != nil { + return apiErr + } + + return validateMutationStatus(body, endpoint) +} + +func mutationAPIError(body []byte) error { + switch mutationResponseRoot(body) { + case "errors": + var errs models.ErrorsResponse + if err := xml.Unmarshal(body, &errs); err == nil && len(errs.Errors) > 0 { + return &errs + } + case "error": + var apiError models.APIError + if err := xml.Unmarshal(body, &apiError); err == nil && apiError.Message != "" { + return &apiError + } + } + + return nil +} + +func mutationResponseRoot(body []byte) string { + var root struct { + XMLName xml.Name + } + if xml.Unmarshal(body, &root) != nil { + return "" + } + + return root.XMLName.Local +} + +func validateMutationStatus(body []byte, endpoint string) error { + var status struct { + XMLName xml.Name `xml:"status"` + Value string `xml:",chardata"` + } + if err := xml.Unmarshal(body, &status); err != nil { + return fmt.Errorf("%w: malformed XML response: %w", ErrMutationOutcomeUnknown, err) + } + + if strings.TrimSpace(status.Value) != endpoint { + return fmt.Errorf( + "%w: expected %s, got %s", + ErrMutationOutcomeUnknown, + endpoint, + strings.TrimSpace(string(body)), + ) + } + + return nil +} + // post performs a POST request with XML body func (c *Client) post(endpoint string, payload interface{}) error { url := c.baseURL + endpoint diff --git a/pkg/client/system_settings_test.go b/pkg/client/system_settings_test.go new file mode 100644 index 00000000..2386ce30 --- /dev/null +++ b/pkg/client/system_settings_test.go @@ -0,0 +1,368 @@ +package client + +import ( + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/models" +) + +func TestClientSystemSettingsGETs(t *testing.T) { + tests := []struct { + name string + path string + response string + call func(*Client) error + }{ + { + name: "system timeout", + path: "/systemtimeout", + response: `true`, + call: func(client *Client) error { + setting, err := client.GetSystemTimeout() + if err == nil && !setting.PowerSavingEnabled { + t.Error("PowerSavingEnabled = false, want true") + } + return err + }, + }, + { + name: "rebroadcast latency", + path: "/rebroadcastlatencymode", + response: ``, + call: func(client *Client) error { + setting, err := client.GetRebroadcastLatencyMode() + if err == nil && (setting.Mode != models.RebroadcastLatencySyncToZone || !setting.Controllable) { + t.Errorf("setting = %#v", setting) + } + return err + }, + }, + { + name: "known language", + path: "/language", + response: `15`, + call: func(client *Client) error { + language, err := client.GetLanguage() + if err == nil && language.Code != models.LanguageCzech { + t.Errorf("Code = %d, want %d", language.Code, models.LanguageCzech) + } + return err + }, + }, + { + name: "unknown language remains readable", + path: "/language", + response: `99`, + call: func(client *Client) error { + language, err := client.GetLanguage() + if err == nil && language.Code != 99 { + t.Errorf("Code = %d, want 99", language.Code) + } + return err + }, + }, + { + name: "Bluetooth info", + path: "/bluetoothInfo", + response: ``, + call: func(client *Client) error { + info, err := client.GetBluetoothInfo() + if err == nil && info.BluetoothMACAddress != "AABBCCDDEEFF" { + t.Errorf("BluetoothMACAddress = %q", info.BluetoothMACAddress) + } + return err + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("method = %s, want GET", r.Method) + } + if r.URL.Path != test.path { + t.Errorf("path = %s, want %s", r.URL.Path, test.path) + } + if got := r.Header.Get("Accept"); got != "application/xml" { + t.Errorf("Accept = %q", got) + } + if got := r.Header.Get("User-Agent"); got != "Bose-SoundTouch-Go-Client/1.0" { + t.Errorf("User-Agent = %q", got) + } + _, _ = io.WriteString(w, test.response) + })) + defer server.Close() + + if err := test.call(createTestClient(server.URL)); err != nil { + t.Fatalf("call(): %v", err) + } + }) + } +} + +func TestClientSystemSettingsGETErrors(t *testing.T) { + tests := []struct { + name string + status int + body string + call func(*Client) error + }{ + { + name: "malformed system timeout XML", + body: ``, + call: func(client *Client) error { _, err := client.GetSystemTimeout(); return err }, + }, + { + name: "incomplete Bluetooth XML", + body: ``, + call: func(client *Client) error { _, err := client.GetBluetoothInfo(); return err }, + }, + { + name: "language non-200", + status: http.StatusNotFound, + body: `unsupported`, + call: func(client *Client) error { _, err := client.GetLanguage(); return err }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + status := test.status + if status == 0 { + status = http.StatusOK + } + w.WriteHeader(status) + _, _ = io.WriteString(w, test.body) + })) + defer server.Close() + + if err := test.call(createTestClient(server.URL)); err == nil { + t.Fatal("call() unexpectedly succeeded") + } + }) + } +} + +func TestClientSystemSettingsPOSTs(t *testing.T) { + tests := []struct { + name string + path string + body string + call func(*Client) error + }{ + { + name: "system timeout", + path: "/systemtimeout", + body: `false`, + call: func(client *Client) error { + return client.SetSystemTimeout(&models.SystemTimeout{PowerSavingEnabled: false}) + }, + }, + { + name: "rebroadcast latency", + path: "/rebroadcastlatencymode", + body: ``, + call: func(client *Client) error { + return client.SetRebroadcastLatencyMode(models.RebroadcastLatencySyncToRoom) + }, + }, + { + name: "language", + path: "/language", + body: `3`, + call: func(client *Client) error { return client.SetLanguage(models.LanguageEnglish) }, + }, + { + name: "source rename with account", + path: "/nameSource", + body: `Turntable`, + call: func(client *Client) error { return client.RenameSource("AUX", "AUX1", "Turntable") }, + }, + { + name: "source rename without account", + path: "/nameSource", + body: `Phone`, + call: func(client *Client) error { return client.RenameSource("BLUETOOTH", "", "Phone") }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if r.URL.Path != test.path { + t.Errorf("path = %s, want %s", r.URL.Path, test.path) + } + if got := r.Header.Get("Content-Type"); got != "application/xml" { + t.Errorf("Content-Type = %q", got) + } + if got := r.Header.Get("Accept"); got != "application/xml" { + t.Errorf("Accept = %q", got) + } + if got := r.Header.Get("User-Agent"); got != "Bose-SoundTouch-Go-Client/1.0" { + t.Errorf("User-Agent = %q", got) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("ReadAll(): %v", err) + } + if string(body) != test.body { + t.Errorf("body = %q, want %q", body, test.body) + } + })) + defer server.Close() + + if err := test.call(createTestClient(server.URL)); err != nil { + t.Fatalf("call(): %v", err) + } + }) + } +} + +func TestClientSystemSettingsPOSTValidation(t *testing.T) { + client := createTestClient("http://127.0.0.1:1") + for _, test := range []struct { + name string + call func() error + }{ + {"nil timeout", func() error { return client.SetSystemTimeout(nil) }}, + {"unknown latency", func() error { return client.SetRebroadcastLatencyMode("OTHER") }}, + {"unknown language", func() error { return client.SetLanguage(99) }}, + {"missing source", func() error { return client.RenameSource("", "", "Name") }}, + {"missing item name", func() error { return client.RenameSource("AUX", "AUX1", "") }}, + } { + t.Run(test.name, func(t *testing.T) { + if err := test.call(); err == nil { + t.Fatal("call() unexpectedly succeeded") + } + }) + } +} + +func TestClientSystemSettingsPOSTNon200(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "rejected", http.StatusBadRequest) + })) + defer server.Close() + + err := createTestClient(server.URL).SetLanguage(models.LanguageEnglish) + if err == nil || !strings.Contains(err.Error(), "400") { + t.Fatalf("SetLanguage() error = %v, want status 400", err) + } +} + +func TestClientBluetoothMutatingGETs(t *testing.T) { + for _, test := range []struct { + name string + path string + call func(*Client) error + }{ + {"enter pairing mode", "/enterPairingMode", func(client *Client) error { return client.EnterPairingMode() }}, + {"clear paired list", "/clearPairedList", func(client *Client) error { return client.ClearPairedList() }}, + {"enter Bluetooth pairing", "/enterBluetoothPairing", func(client *Client) error { return client.EnterBluetoothPairing() }}, + {"clear Bluetooth paired", "/clearBluetoothPaired", func(client *Client) error { return client.ClearBluetoothPaired() }}, + } { + t.Run(test.name, func(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet { + t.Errorf("method = %s, want GET", r.Method) + } + if r.URL.Path != test.path { + t.Errorf("path = %s, want %s", r.URL.Path, test.path) + } + if got := r.Header.Get("Accept"); got != "application/xml" { + t.Errorf("Accept = %q", got) + } + if got := r.Header.Get("User-Agent"); got != "Bose-SoundTouch-Go-Client/1.0" { + t.Errorf("User-Agent = %q", got) + } + _, _ = io.WriteString(w, ``+test.path+``) + })) + defer server.Close() + + if err := test.call(createTestClient(server.URL)); err != nil { + t.Fatalf("call(): %v", err) + } + if requests != 1 { + t.Fatalf("requests = %d, want 1", requests) + } + }) + } +} + +func TestClientMutatingGETDoesNotFollowRedirect(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.URL.Path == "/enterBluetoothPairing" { + http.Redirect(w, r, "/replayed", http.StatusTemporaryRedirect) + return + } + _, _ = io.WriteString(w, `replayed`) + })) + defer server.Close() + + err := createTestClient(server.URL).EnterBluetoothPairing() + if err == nil || !strings.Contains(err.Error(), "307") { + t.Fatalf("EnterBluetoothPairing() error = %v, want status 307", err) + } + if requests != 1 { + t.Fatalf("requests = %d, want 1", requests) + } +} + +func TestClientMutatingGETRejectsErrorEnvelopeWithHTTP200(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `rejected`) + })) + defer server.Close() + + err := createTestClient(server.URL).EnterBluetoothPairing() + var errs *models.ErrorsResponse + if !errors.As(err, &errs) { + t.Fatalf("EnterBluetoothPairing() error = %T %v, want ErrorsResponse", err, err) + } +} + +func TestClientMutatingGETMarksUnstructuredHTTPFailureUnknown(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "internal failure", http.StatusInternalServerError) + })) + defer server.Close() + + err := createTestClient(server.URL).EnterBluetoothPairing() + if !errors.Is(err, ErrMutationOutcomeUnknown) { + t.Fatalf("EnterBluetoothPairing() error = %v, want ErrMutationOutcomeUnknown", err) + } + if !strings.Contains(err.Error(), "status 500") { + t.Fatalf("EnterBluetoothPairing() error = %v, want status 500", err) + } +} + +func TestClientMutatingGETMarksLostResponseUnknown(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + connection, _, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Errorf("Hijack(): %v", err) + return + } + _ = connection.Close() + })) + defer server.Close() + + err := createTestClient(server.URL).EnterBluetoothPairing() + if !errors.Is(err, ErrMutationOutcomeUnknown) { + t.Fatalf("EnterBluetoothPairing() error = %v, want ErrMutationOutcomeUnknown", err) + } +} diff --git a/pkg/models/clockdisplay.go b/pkg/models/clockdisplay.go index 2631a5c8..1dc35419 100644 --- a/pkg/models/clockdisplay.go +++ b/pkg/models/clockdisplay.go @@ -31,6 +31,7 @@ type ClockDisplay struct { XMLName xml.Name `xml:"clockDisplay"` DeviceID string Enabled bool + enabledSet bool Format string // public-facing values: "12", "24", "auto" Brightness int AutoDim bool // not on the device's wire format; preserved for API compat @@ -76,6 +77,7 @@ func mapFromWireFormat(wire string) string { // either because it appears in legacy captures or for forward-compat with // firmwares that may revert. func (c *ClockDisplay) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + c.enabledSet = false applyClockDisplayOuterAttrs(c, start.Attr) for { @@ -121,6 +123,7 @@ func applyClockDisplayOuterAttrs(c *ClockDisplay, attrs []xml.Attr) { c.DeviceID = attr.Value case "enabled": c.Enabled = attr.Value == "true" + c.enabledSet = true case "format": c.Format = attr.Value case "brightness": @@ -143,6 +146,7 @@ func applyClockConfigAttrs(c *ClockDisplay, attrs []xml.Attr) { c.TimeZone = attr.Value case "userEnable": c.Enabled = attr.Value == "true" + c.enabledSet = true case "timeFormat": if mapped := mapFromWireFormat(attr.Value); mapped != "" { c.Format = mapped @@ -170,6 +174,11 @@ func (c *ClockDisplay) IsEnabled() bool { return c.Enabled } +// HasEnabled reports whether the enabled value was present in the XML response. +func (c *ClockDisplay) HasEnabled() bool { + return c.enabledSet +} + // GetFormat returns the clock display format (12/24 hour) func (c *ClockDisplay) GetFormat() string { if c.Format == "" { diff --git a/pkg/models/clockdisplay_test.go b/pkg/models/clockdisplay_test.go index 9be1f26d..36b064d8 100644 --- a/pkg/models/clockdisplay_test.go +++ b/pkg/models/clockdisplay_test.go @@ -98,6 +98,57 @@ func TestClockDisplay_UnmarshalXML(t *testing.T) { } } +func TestClockDisplay_UnmarshalXML_EnabledPresence(t *testing.T) { + tests := []struct { + name string + xmlData string + wantEnabled bool + wantPresent bool + }{ + { + name: "nested present true", + xmlData: ``, + wantEnabled: true, + wantPresent: true, + }, + { + name: "nested present false", + xmlData: ``, + wantEnabled: false, + wantPresent: true, + }, + { + name: "legacy present", + xmlData: ``, + wantEnabled: true, + wantPresent: true, + }, + { + name: "omitted", + xmlData: ``, + wantEnabled: false, + wantPresent: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got ClockDisplay + if err := xml.Unmarshal([]byte(tt.xmlData), &got); err != nil { + t.Fatalf("Failed to unmarshal XML: %v", err) + } + + if got.Enabled != tt.wantEnabled { + t.Errorf("Enabled = %v, want %v", got.Enabled, tt.wantEnabled) + } + + if got.HasEnabled() != tt.wantPresent { + t.Errorf("HasEnabled() = %v, want %v", got.HasEnabled(), tt.wantPresent) + } + }) + } +} + func TestClockDisplay_IsEnabled(t *testing.T) { tests := []struct { name string diff --git a/pkg/models/nowplaying.go b/pkg/models/nowplaying.go index d3b678c1..ec273c93 100644 --- a/pkg/models/nowplaying.go +++ b/pkg/models/nowplaying.go @@ -8,29 +8,30 @@ import ( // NowPlaying represents the current playback information from /now_playing endpoint type NowPlaying struct { - XMLName xml.Name `xml:"nowPlaying"` - DeviceID string `xml:"deviceID,attr"` - Source string `xml:"source,attr"` - SourceAccount string `xml:"sourceAccount,attr,omitempty"` - ContentItem *ContentItem `xml:"ContentItem,omitempty"` - Track string `xml:"track,omitempty"` - Artist string `xml:"artist,omitempty"` - Album string `xml:"album,omitempty"` - StationName string `xml:"stationName,omitempty"` - Art *Art `xml:"art,omitempty"` - Time *Time `xml:"time,omitempty"` - SkipEnabled *SkipEnabled `xml:"skipEnabled,omitempty"` - FavoriteEnabled *FavoriteEnabled `xml:"favoriteEnabled,omitempty"` - PlayStatus PlayStatus `xml:"playStatus,omitempty"` - ShuffleSetting ShuffleSetting `xml:"shuffleSetting,omitempty"` - RepeatSetting RepeatSetting `xml:"repeatSetting,omitempty"` - SkipPreviousEnabled *SkipPreviousEnabled `xml:"skipPreviousEnabled,omitempty"` - SeekSupported *SeekSupported `xml:"seekSupported,omitempty"` - StreamType string `xml:"streamType,omitempty"` - TrackID string `xml:"trackID,omitempty"` - Position *Position `xml:"position,omitempty"` - Description string `xml:"description,omitempty"` - StationLocation string `xml:"stationLocation,omitempty"` + XMLName xml.Name `xml:"nowPlaying"` + DeviceID string `xml:"deviceID,attr"` + Source string `xml:"source,attr"` + SourceAccount string `xml:"sourceAccount,attr,omitempty"` + ContentItem *ContentItem `xml:"ContentItem,omitempty"` + Track string `xml:"track,omitempty"` + Artist string `xml:"artist,omitempty"` + Album string `xml:"album,omitempty"` + StationName string `xml:"stationName,omitempty"` + Art *Art `xml:"art,omitempty"` + Time *Time `xml:"time,omitempty"` + SkipEnabled *SkipEnabled `xml:"skipEnabled,omitempty"` + FavoriteEnabled *FavoriteEnabled `xml:"favoriteEnabled,omitempty"` + PlayStatus PlayStatus `xml:"playStatus,omitempty"` + ShuffleSetting ShuffleSetting `xml:"shuffleSetting,omitempty"` + RepeatSetting RepeatSetting `xml:"repeatSetting,omitempty"` + SkipPreviousEnabled *SkipPreviousEnabled `xml:"skipPreviousEnabled,omitempty"` + SeekSupported *SeekSupported `xml:"seekSupported,omitempty"` + StreamType string `xml:"streamType,omitempty"` + TrackID string `xml:"trackID,omitempty"` + Position *Position `xml:"position,omitempty"` + Description string `xml:"description,omitempty"` + StationLocation string `xml:"stationLocation,omitempty"` + ConnectionStatusInfo *ConnectionStatusInfo `xml:"connectionStatusInfo,omitempty"` } // ContentItem represents metadata about the currently playing content @@ -44,6 +45,17 @@ type ContentItem struct { ContainerArt string `xml:"containerArt,omitempty"` } +// ConnectionStatusInfo describes the active Bluetooth connection state. +type ConnectionStatusInfo struct { + DeviceName string `xml:"deviceName,attr"` + Status string `xml:"status,attr"` +} + +// IsDiscoverable reports whether the speaker is advertising for pairing. +func (c *ConnectionStatusInfo) IsDiscoverable() bool { + return c != nil && c.Status == "DISCOVERABLE" +} + // Art represents album artwork information type Art struct { ArtImageStatus string `xml:"artImageStatus,attr"` diff --git a/pkg/models/nowplaying_test.go b/pkg/models/nowplaying_test.go index d6a25933..b83f3b71 100644 --- a/pkg/models/nowplaying_test.go +++ b/pkg/models/nowplaying_test.go @@ -307,6 +307,35 @@ func TestNowPlaying_UnmarshalXML(t *testing.T) { } } +func TestNowPlaying_BluetoothConnectionStatusInfo(t *testing.T) { + input := `` + var nowPlaying NowPlaying + if err := xml.Unmarshal([]byte(input), &nowPlaying); err != nil { + t.Fatalf("xml.Unmarshal(): %v", err) + } + if nowPlaying.ConnectionStatusInfo == nil { + t.Fatal("ConnectionStatusInfo is nil") + } + if nowPlaying.ConnectionStatusInfo.DeviceName != "Phone" { + t.Fatalf("DeviceName = %q, want Phone", nowPlaying.ConnectionStatusInfo.DeviceName) + } + if nowPlaying.ConnectionStatusInfo.Status != "DISCOVERABLE" { + t.Fatalf("Status = %q, want DISCOVERABLE", nowPlaying.ConnectionStatusInfo.Status) + } + if !nowPlaying.ConnectionStatusInfo.IsDiscoverable() { + t.Fatal("IsDiscoverable() = false, want true") + } + + nowPlaying.ConnectionStatusInfo.Status = "CONNECTED" + if nowPlaying.ConnectionStatusInfo.IsDiscoverable() { + t.Fatal("IsDiscoverable() = true for CONNECTED") + } + var absent *ConnectionStatusInfo + if absent.IsDiscoverable() { + t.Fatal("nil IsDiscoverable() = true") + } +} + func TestNowPlaying_RadioStation(t *testing.T) { xmlData := ` diff --git a/pkg/models/systemsettings.go b/pkg/models/systemsettings.go new file mode 100644 index 00000000..7232c50b --- /dev/null +++ b/pkg/models/systemsettings.go @@ -0,0 +1,316 @@ +package models + +import ( + "encoding/xml" + "fmt" + "strconv" + "strings" +) + +// SystemTimeout is the power-saving setting returned by /systemtimeout. +type SystemTimeout struct { + XMLName xml.Name `xml:"systemtimeout"` + PowerSavingEnabled bool `xml:"powersaving_enabled"` +} + +// Validate checks whether the update model is present. +func (s *SystemTimeout) Validate() error { + if s == nil { + return fmt.Errorf("system timeout is nil") + } + + return nil +} + +// UnmarshalXML rejects responses that omit the required power-saving value. +func (s *SystemTimeout) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + if start.Name.Local != "systemtimeout" { + return fmt.Errorf("expected systemtimeout element, got %s", start.Name.Local) + } + + var wire struct { + PowerSavingEnabled *bool `xml:"powersaving_enabled"` + } + if err := d.DecodeElement(&wire, &start); err != nil { + return err + } + + if wire.PowerSavingEnabled == nil { + return fmt.Errorf("systemtimeout is missing powersaving_enabled") + } + + s.XMLName = start.Name + s.PowerSavingEnabled = *wire.PowerSavingEnabled + + return nil +} + +// RebroadcastLatencyModeValue is a firmware-supported rebroadcast timing mode. +type RebroadcastLatencyModeValue string + +const ( + // RebroadcastLatencySyncToRoom prioritizes the selected room for video sync. + RebroadcastLatencySyncToRoom RebroadcastLatencyModeValue = "SYNC_TO_ROOM" + // RebroadcastLatencySyncToZone prioritizes synchronization across the zone. + RebroadcastLatencySyncToZone RebroadcastLatencyModeValue = "SYNC_TO_ZONE" +) + +// Validate rejects values that the SoundTouch firmware does not understand. +func (m RebroadcastLatencyModeValue) Validate() error { + switch m { + case RebroadcastLatencySyncToRoom, RebroadcastLatencySyncToZone: + return nil + default: + return fmt.Errorf("unknown rebroadcast latency mode %q", m) + } +} + +// RebroadcastLatencyMode is the setting returned by /rebroadcastlatencymode. +// Controllable is response metadata and is not included in update requests. +type RebroadcastLatencyMode struct { + XMLName xml.Name `xml:"rebroadcastlatencymode"` + Mode RebroadcastLatencyModeValue `xml:"mode,attr"` + Controllable bool `xml:"controllable,attr"` +} + +// Validate checks whether the reported mode is supported. +func (r *RebroadcastLatencyMode) Validate() error { + if r == nil { + return fmt.Errorf("rebroadcast latency mode is nil") + } + + return r.Mode.Validate() +} + +// UnmarshalXML rejects responses that omit either required attribute. +func (r *RebroadcastLatencyMode) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + if start.Name.Local != "rebroadcastlatencymode" { + return fmt.Errorf("expected rebroadcastlatencymode element, got %s", start.Name.Local) + } + + var wire struct { + Mode *RebroadcastLatencyModeValue `xml:"mode,attr"` + Controllable *bool `xml:"controllable,attr"` + } + if err := d.DecodeElement(&wire, &start); err != nil { + return err + } + + if wire.Mode == nil { + return fmt.Errorf("rebroadcastlatencymode is missing mode") + } + + if wire.Controllable == nil { + return fmt.Errorf("rebroadcastlatencymode is missing controllable") + } + + if err := wire.Mode.Validate(); err != nil { + return err + } + + r.XMLName = start.Name + r.Mode = *wire.Mode + r.Controllable = *wire.Controllable + + return nil +} + +// RebroadcastLatencyModeRequest is the update body accepted by the firmware. +type RebroadcastLatencyModeRequest struct { + XMLName xml.Name `xml:"rebroadcastlatencymode"` + Mode RebroadcastLatencyModeValue `xml:"mode,attr"` +} + +// Validate checks whether the requested latency mode is known. +func (r *RebroadcastLatencyModeRequest) Validate() error { + if r == nil { + return fmt.Errorf("rebroadcast latency mode request is nil") + } + + return r.Mode.Validate() +} + +// LanguageCode is a SoundTouch system-language identifier. +type LanguageCode int + +// Supported system language codes match the set exposed by Stockholm. +const ( + LanguageDanish LanguageCode = 1 + LanguageGerman LanguageCode = 2 + LanguageEnglish LanguageCode = 3 + LanguageSpanish LanguageCode = 4 + LanguageFrench LanguageCode = 5 + LanguageItalian LanguageCode = 6 + LanguageDutch LanguageCode = 7 + LanguageSwedish LanguageCode = 8 + LanguageJapanese LanguageCode = 9 + LanguageSimplifiedChinese LanguageCode = 10 + LanguageTraditionalChinese LanguageCode = 11 + LanguageKorean LanguageCode = 12 + LanguageThai LanguageCode = 13 + LanguageCzech LanguageCode = 15 + LanguageFinnish LanguageCode = 16 + LanguageGreek LanguageCode = 17 + LanguageNorwegian LanguageCode = 18 + LanguagePolish LanguageCode = 19 + LanguagePortuguese LanguageCode = 20 + LanguageRomanian LanguageCode = 21 + LanguageRussian LanguageCode = 22 + LanguageSlovenian LanguageCode = 23 + LanguageTurkish LanguageCode = 24 + LanguageHungarian LanguageCode = 25 +) + +var knownSystemLanguageNames = map[LanguageCode]string{ + LanguageDanish: "Dansk", + LanguageGerman: "Deutsch", + LanguageEnglish: "English", + LanguageSpanish: "Español", + LanguageFrench: "Français", + LanguageItalian: "Italiano", + LanguageDutch: "Nederlands", + LanguageSwedish: "Svenska", + LanguageJapanese: "日本語", + LanguageSimplifiedChinese: "简体中文", + LanguageTraditionalChinese: "繁體中文", + LanguageKorean: "한국어", + LanguageThai: "ไทย", + LanguageCzech: "Čeština", + LanguageFinnish: "Suomi", + LanguageGreek: "Ελληνικά", + LanguageNorwegian: "Norsk", + LanguagePolish: "Polski", + LanguagePortuguese: "Português", + LanguageRomanian: "Română", + LanguageRussian: "Русский", + LanguageSlovenian: "Slovenščina", + LanguageTurkish: "Türkçe", + LanguageHungarian: "Magyar", +} + +// SystemLanguageNames returns the language labels and codes used by Stockholm. +// Each call returns a copy so callers cannot mutate shared validation state. +func SystemLanguageNames() map[LanguageCode]string { + names := make(map[LanguageCode]string, len(knownSystemLanguageNames)) + for code, name := range knownSystemLanguageNames { + names[code] = name + } + + return names +} + +// Validate rejects language codes that Stockholm does not offer for writes. +func (l LanguageCode) Validate() error { + if _, ok := knownSystemLanguageNames[l]; !ok { + return fmt.Errorf("unknown system language code %d", l) + } + + return nil +} + +// SystemLanguage is the integer value read from or written to /language. +// Unknown values are retained when reading so newer firmware remains usable. +type SystemLanguage struct { + XMLName xml.Name `xml:"sysLanguage"` + Code LanguageCode `xml:",chardata"` +} + +// UnmarshalXML retains unknown integer codes for forward compatibility while +// rejecting a response that omits the language value entirely. +func (l *SystemLanguage) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + if start.Name.Local != "sysLanguage" { + return fmt.Errorf("expected sysLanguage element, got %s", start.Name.Local) + } + + var raw string + if err := d.DecodeElement(&raw, &start); err != nil { + return err + } + + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("sysLanguage is missing its language code") + } + + code, err := strconv.Atoi(raw) + if err != nil { + return fmt.Errorf("invalid sysLanguage code %q: %w", raw, err) + } + + l.XMLName = start.Name + l.Code = LanguageCode(code) + + return nil +} + +// Validate checks whether this language can be sent to the speaker. +func (l *SystemLanguage) Validate() error { + if l == nil { + return fmt.Errorf("system language is nil") + } + + return l.Code.Validate() +} + +// BluetoothInfo is the speaker Bluetooth adapter information. +type BluetoothInfo struct { + XMLName xml.Name `xml:"BluetoothInfo"` + BluetoothMACAddress string `xml:"BluetoothMACAddress,attr"` +} + +// Validate requires the adapter address returned by the firmware. +func (b *BluetoothInfo) Validate() error { + if b == nil { + return fmt.Errorf("bluetooth info is nil") + } + + if b.BluetoothMACAddress == "" { + return fmt.Errorf("bluetooth info is missing BluetoothMACAddress") + } + + return nil +} + +// UnmarshalXML rejects responses without the adapter address. +func (b *BluetoothInfo) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + if start.Name.Local != "BluetoothInfo" { + return fmt.Errorf("expected BluetoothInfo element, got %s", start.Name.Local) + } + + var wire struct { + BluetoothMACAddress string `xml:"BluetoothMACAddress,attr"` + } + if err := d.DecodeElement(&wire, &start); err != nil { + return err + } + + b.XMLName = start.Name + b.BluetoothMACAddress = wire.BluetoothMACAddress + + return b.Validate() +} + +// SourceRenameRequest is the exact update body accepted by /nameSource. +type SourceRenameRequest struct { + XMLName xml.Name `xml:"ContentItem"` + Source string `xml:"source,attr"` + SourceAccount string `xml:"sourceAccount,attr,omitempty"` + ItemName string `xml:"itemName"` +} + +// Validate requires the source identity and replacement display name. +func (r *SourceRenameRequest) Validate() error { + if r == nil { + return fmt.Errorf("source rename request is nil") + } + + if r.Source == "" { + return fmt.Errorf("source is required") + } + + if r.ItemName == "" { + return fmt.Errorf("item name is required") + } + + return nil +} diff --git a/pkg/models/systemsettings_test.go b/pkg/models/systemsettings_test.go new file mode 100644 index 00000000..6f893315 --- /dev/null +++ b/pkg/models/systemsettings_test.go @@ -0,0 +1,207 @@ +package models + +import ( + "encoding/xml" + "reflect" + "testing" +) + +func TestSystemTimeoutXML(t *testing.T) { + for _, enabled := range []bool{true, false} { + input := `` + map[bool]string{true: "true", false: "false"}[enabled] + `` + var setting SystemTimeout + if err := xml.Unmarshal([]byte(input), &setting); err != nil { + t.Fatalf("xml.Unmarshal(%q): %v", input, err) + } + if setting.PowerSavingEnabled != enabled { + t.Fatalf("PowerSavingEnabled = %t, want %t", setting.PowerSavingEnabled, enabled) + } + + got, err := xml.Marshal(setting) + if err != nil { + t.Fatalf("xml.Marshal(): %v", err) + } + if string(got) != input { + t.Fatalf("xml.Marshal() = %q, want %q", got, input) + } + } +} + +func TestSystemTimeoutRejectsInvalidXML(t *testing.T) { + for _, input := range []string{ + ``, + `maybe`, + `true`, + } { + var setting SystemTimeout + if err := xml.Unmarshal([]byte(input), &setting); err == nil { + t.Errorf("xml.Unmarshal(%q) unexpectedly succeeded", input) + } + } +} + +func TestRebroadcastLatencyModeXML(t *testing.T) { + input := `` + var setting RebroadcastLatencyMode + if err := xml.Unmarshal([]byte(input), &setting); err != nil { + t.Fatalf("xml.Unmarshal(): %v", err) + } + if setting.Mode != RebroadcastLatencySyncToZone || !setting.Controllable { + t.Fatalf("setting = %#v", setting) + } + if err := setting.Validate(); err != nil { + t.Fatalf("Validate(): %v", err) + } + + request := RebroadcastLatencyModeRequest{Mode: RebroadcastLatencySyncToRoom} + if err := request.Validate(); err != nil { + t.Fatalf("Validate(): %v", err) + } + got, err := xml.Marshal(request) + if err != nil { + t.Fatalf("xml.Marshal(): %v", err) + } + if want := ``; string(got) != want { + t.Fatalf("xml.Marshal() = %q, want %q", got, want) + } +} + +func TestRebroadcastLatencyModeValidation(t *testing.T) { + for _, input := range []string{ + ``, + ``, + ``, + ``, + } { + var setting RebroadcastLatencyMode + if err := xml.Unmarshal([]byte(input), &setting); err == nil { + t.Errorf("xml.Unmarshal(%q) unexpectedly succeeded", input) + } + } + + request := RebroadcastLatencyModeRequest{Mode: "OTHER"} + if err := request.Validate(); err == nil { + t.Error("Validate() unexpectedly accepted an unknown mode") + } +} + +func TestSystemLanguageCodesMatchStockholm(t *testing.T) { + want := map[LanguageCode]string{ + 1: "Dansk", 2: "Deutsch", 3: "English", 4: "Español", 5: "Français", + 6: "Italiano", 7: "Nederlands", 8: "Svenska", 9: "日本語", 10: "简体中文", + 11: "繁體中文", 12: "한국어", 13: "ไทย", 15: "Čeština", 16: "Suomi", + 17: "Ελληνικά", 18: "Norsk", 19: "Polski", 20: "Português", 21: "Română", + 22: "Русский", 23: "Slovenščina", 24: "Türkçe", 25: "Magyar", + } + if !reflect.DeepEqual(SystemLanguageNames(), want) { + t.Fatalf("SystemLanguageNames() = %#v, want %#v", SystemLanguageNames(), want) + } + if LanguageEnglish != 3 || LanguageCzech != 15 { + t.Fatalf("English/Czech codes = %d/%d, want 3/15", LanguageEnglish, LanguageCzech) + } +} + +func TestSystemLanguageReadAndWriteValidation(t *testing.T) { + for _, test := range []struct { + input string + code LanguageCode + }{ + {`15`, LanguageCzech}, + {`99`, 99}, + } { + var language SystemLanguage + if err := xml.Unmarshal([]byte(test.input), &language); err != nil { + t.Fatalf("xml.Unmarshal(%q): %v", test.input, err) + } + if language.Code != test.code { + t.Fatalf("Code = %d, want %d", language.Code, test.code) + } + } + + known := SystemLanguage{Code: LanguageEnglish} + if err := known.Validate(); err != nil { + t.Fatalf("Validate(English): %v", err) + } + got, err := xml.Marshal(known) + if err != nil { + t.Fatalf("xml.Marshal(): %v", err) + } + if want := `3`; string(got) != want { + t.Fatalf("xml.Marshal() = %q, want %q", got, want) + } + + unknown := SystemLanguage{Code: 99} + if err := unknown.Validate(); err == nil { + t.Error("Validate() unexpectedly accepted unknown code 99") + } + names := SystemLanguageNames() + names[99] = "Future language" + if err := unknown.Validate(); err == nil { + t.Error("Validate() was widened by a caller-modified display map") + } + if _, ok := SystemLanguageNames()[99]; ok { + t.Error("SystemLanguageNames() returned shared mutable state") + } + var missing SystemLanguage + if err := xml.Unmarshal([]byte(``), &missing); err == nil { + t.Error("xml.Unmarshal() unexpectedly accepted a missing language code") + } + var malformed SystemLanguage + if err := xml.Unmarshal([]byte(`English`), &malformed); err == nil { + t.Error("xml.Unmarshal() unexpectedly accepted a non-integer language") + } +} + +func TestBluetoothInfoXML(t *testing.T) { + input := `` + var info BluetoothInfo + if err := xml.Unmarshal([]byte(input), &info); err != nil { + t.Fatalf("xml.Unmarshal(): %v", err) + } + if info.BluetoothMACAddress != "AABBCCDDEEFF" { + t.Fatalf("BluetoothMACAddress = %q", info.BluetoothMACAddress) + } + if err := info.Validate(); err != nil { + t.Fatalf("Validate(): %v", err) + } + if err := (*BluetoothInfo)(nil).Validate(); err == nil { + t.Fatal("nil Validate() unexpectedly succeeded") + } +} + +func TestSourceRenameRequestXMLAndValidation(t *testing.T) { + for _, test := range []struct { + request SourceRenameRequest + want string + }{ + { + request: SourceRenameRequest{Source: "AUX", SourceAccount: "AUX1", ItemName: "Turntable"}, + want: `Turntable`, + }, + { + request: SourceRenameRequest{Source: "BLUETOOTH", ItemName: "Phone"}, + want: `Phone`, + }, + } { + if err := test.request.Validate(); err != nil { + t.Fatalf("Validate(): %v", err) + } + got, err := xml.Marshal(test.request) + if err != nil { + t.Fatalf("xml.Marshal(): %v", err) + } + if string(got) != test.want { + t.Fatalf("xml.Marshal() = %q, want %q", got, test.want) + } + } + + for _, request := range []*SourceRenameRequest{ + nil, + {ItemName: "Name"}, + {Source: "AUX"}, + } { + if err := request.Validate(); err == nil { + t.Errorf("Validate(%#v) unexpectedly succeeded", request) + } + } +} diff --git a/pkg/service/setup/init_plan.go b/pkg/service/setup/init_plan.go index 19dbad29..2cd0127b 100644 --- a/pkg/service/setup/init_plan.go +++ b/pkg/service/setup/init_plan.go @@ -6,6 +6,7 @@ import ( "fmt" "time" + "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/gesellix/bose-soundtouch/pkg/service/datastore" ) @@ -201,7 +202,7 @@ func (m *Manager) ExecuteInitPlan(ctx context.Context, plan InitPlan, progress P } // applyInitPlanDefaults validates required fields and fills in defaults -// from Manager.ServerURL / sysLanguage 2 / DefaultMargeAuthToken. +// from Manager.ServerURL / LanguageEnglish / DefaultMargeAuthToken. func applyInitPlanDefaults(plan InitPlan, serverURL string) (InitPlan, error) { if plan.DeviceIP == "" { return plan, errors.New("InitPlan.DeviceIP is required") @@ -219,6 +220,10 @@ func applyInitPlanDefaults(plan InitPlan, serverURL string) (InitPlan, error) { plan.Language = LanguageEnglish } + if err := models.LanguageCode(plan.Language).Validate(); err != nil { + return plan, fmt.Errorf("InitPlan.Language: %w", err) + } + if plan.AuthToken == "" { plan.AuthToken = DefaultMargeAuthToken } diff --git a/pkg/service/setup/init_plan_test.go b/pkg/service/setup/init_plan_test.go index 1e466b90..ace4027d 100644 --- a/pkg/service/setup/init_plan_test.go +++ b/pkg/service/setup/init_plan_test.go @@ -145,7 +145,7 @@ func TestExecuteInitPlan_FactoryReset_GeneratesAccountAndRunsAllSteps(t *testing wantCalls := []string{ "Start", "IdentifyEnter(300000)", - "SetLanguage(2)", + "SetLanguage(3)", "Enter", "IdentifyLeave", "SetName(Living Room)", @@ -172,6 +172,24 @@ func TestExecuteInitPlan_FactoryReset_GeneratesAccountAndRunsAllSteps(t *testing } } +func TestExecuteInitPlanRejectsInvalidLanguageBeforeAnyStep(t *testing.T) { + manager := &Manager{ServerURL: "http://aftertouch.example"} + var events []StepEvent + + _, err := manager.ExecuteInitPlan(context.Background(), InitPlan{ + DeviceIP: "192.0.2.10", + Language: 14, + }, func(event StepEvent) { + events = append(events, event) + }) + if err == nil { + t.Fatal("expected invalid language to be rejected") + } + if len(events) != 0 { + t.Fatalf("invalid plan started %d steps: %+v", len(events), events) + } +} + func TestExecuteInitPlan_ReusesExistingAccountUUID(t *testing.T) { info := &fakeInfoResponder{ deviceID: "AABBCCDDEEFF", diff --git a/pkg/service/setup/setup_session.go b/pkg/service/setup/setup_session.go index 5ffb4ada..4b5900d7 100644 --- a/pkg/service/setup/setup_session.go +++ b/pkg/service/setup/setup_session.go @@ -11,6 +11,7 @@ import ( "sync/atomic" "time" + "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/gorilla/websocket" ) @@ -18,9 +19,9 @@ const ( defaultSetupStepTimeout = 8 * time.Second setupHandshakeTimeout = 10 * time.Second - // LanguageEnglish is the sysLanguage code for English. ‹2› is the - // value the official Bose app sends during English-locale setup. - LanguageEnglish = 2 + // LanguageEnglish is the sysLanguage code used by Stockholm and the + // speaker firmware for English. + LanguageEnglish = int(models.LanguageEnglish) // DefaultMargeAuthToken is the placeholder userAuthToken sent in // when the caller didn't supply one. The @@ -271,9 +272,14 @@ func (s *Session) IdentifyEnter(ctx context.Context, timeoutMs int) error { return s.sendStep(ctx, "setup", "POST", body) } -// SetLanguage POSTs sysLanguage. Code 2 = English. +// SetLanguage POSTs a validated sysLanguage code. func (s *Session) SetLanguage(ctx context.Context, code int) error { + if err := models.LanguageCode(code).Validate(); err != nil { + return fmt.Errorf("SetLanguage: %w", err) + } + body := fmt.Sprintf(`%d`, code) + return s.sendStep(ctx, "language", "POST", body) } diff --git a/pkg/service/setup/setup_session_test.go b/pkg/service/setup/setup_session_test.go index a00ceca4..1411d7b5 100644 --- a/pkg/service/setup/setup_session_test.go +++ b/pkg/service/setup/setup_session_test.go @@ -215,6 +215,20 @@ func TestSession_RequestIDsAreUniquePerStep(t *testing.T) { } } +func TestSession_SetLanguageRejectsUnknownCodeBeforeWrite(t *testing.T) { + f := newFakeSpeaker(t) + s := dialFakeSession(t, f, "X") + + err := s.SetLanguage(context.Background(), 14) + if err == nil { + t.Fatal("expected unknown language code to be rejected") + } + + if frames := f.recordedFrames(); len(frames) != 0 { + t.Fatalf("invalid language wrote %d frames: %v", len(frames), frames) + } +} + func TestSession_IgnoresUpdatesFramesBeforeAck(t *testing.T) { f := newFakeSpeaker(t) f.reply = func(frame string) []string {