feat(client): add capability-driven device settings

This commit is contained in:
Lukáš Lipinský
2026-09-05 13:32:57 +02:00
committed by Tobias Gesellchen
parent 369887f642
commit 0748e4042c
16 changed files with 1341 additions and 35 deletions
+1 -1
View File
@@ -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 <boseServer>/<updateServer> 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)"},
},
@@ -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
+1 -1
View File
@@ -1465,7 +1465,7 @@ soundtouch-cli --host <device> 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.
@@ -136,7 +136,7 @@ The pairing flow uses a setup state machine on the device. States must be sent i
</soundTouchConfigurationUpdated>
</updates>
<!-- 3. Set language (3 = German; adjust as needed) -->
<!-- 3. Set language (3 = English; adjust as needed) -->
<msg><header deviceID="{device_id}" url="language" method="POST">
<request requestID="23"></request>
</header><body><sysLanguage>3</sysLanguage></body></msg>
+270 -3
View File
@@ -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 <status>{endpoint}</status>.
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 <status>%s</status>, 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
+368
View File
@@ -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: `<systemtimeout><powersaving_enabled>true</powersaving_enabled></systemtimeout>`,
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: `<rebroadcastlatencymode mode="SYNC_TO_ZONE" controllable="true"/>`,
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: `<sysLanguage>15</sysLanguage>`,
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: `<sysLanguage>99</sysLanguage>`,
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: `<BluetoothInfo BluetoothMACAddress="AABBCCDDEEFF"/>`,
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: `<systemtimeout><powersaving_enabled>`,
call: func(client *Client) error { _, err := client.GetSystemTimeout(); return err },
},
{
name: "incomplete Bluetooth XML",
body: `<BluetoothInfo/>`,
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: `<systemtimeout><powersaving_enabled>false</powersaving_enabled></systemtimeout>`,
call: func(client *Client) error {
return client.SetSystemTimeout(&models.SystemTimeout{PowerSavingEnabled: false})
},
},
{
name: "rebroadcast latency",
path: "/rebroadcastlatencymode",
body: `<rebroadcastlatencymode mode="SYNC_TO_ROOM"></rebroadcastlatencymode>`,
call: func(client *Client) error {
return client.SetRebroadcastLatencyMode(models.RebroadcastLatencySyncToRoom)
},
},
{
name: "language",
path: "/language",
body: `<sysLanguage>3</sysLanguage>`,
call: func(client *Client) error { return client.SetLanguage(models.LanguageEnglish) },
},
{
name: "source rename with account",
path: "/nameSource",
body: `<ContentItem source="AUX" sourceAccount="AUX1"><itemName>Turntable</itemName></ContentItem>`,
call: func(client *Client) error { return client.RenameSource("AUX", "AUX1", "Turntable") },
},
{
name: "source rename without account",
path: "/nameSource",
body: `<ContentItem source="BLUETOOTH"><itemName>Phone</itemName></ContentItem>`,
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, `<status>`+test.path+`</status>`)
}))
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, `<status>replayed</status>`)
}))
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, `<errors deviceID="AABBCCDDEEFF"><error value="1029" name="UNKNOWN_ACTION_ERROR">rejected</error></errors>`)
}))
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)
}
}
+9
View File
@@ -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 == "" {
+51
View File
@@ -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: `<clockDisplay><clockConfig userEnable="true"/></clockDisplay>`,
wantEnabled: true,
wantPresent: true,
},
{
name: "nested present false",
xmlData: `<clockDisplay><clockConfig userEnable="false"/></clockDisplay>`,
wantEnabled: false,
wantPresent: true,
},
{
name: "legacy present",
xmlData: `<clockDisplay enabled="true"></clockDisplay>`,
wantEnabled: true,
wantPresent: true,
},
{
name: "omitted",
xmlData: `<clockDisplay><clockConfig brightnessLevel="70"/></clockDisplay>`,
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
+35 -23
View File
@@ -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"`
+29
View File
@@ -307,6 +307,35 @@ func TestNowPlaying_UnmarshalXML(t *testing.T) {
}
}
func TestNowPlaying_BluetoothConnectionStatusInfo(t *testing.T) {
input := `<nowPlaying source="BLUETOOTH"><connectionStatusInfo deviceName="Phone" status="DISCOVERABLE"></connectionStatusInfo></nowPlaying>`
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 := `<?xml version="1.0" encoding="UTF-8" ?>
<nowPlaying deviceID="AABBCCDDEEFF" source="TUNEIN">
+316
View File
@@ -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
}
+207
View File
@@ -0,0 +1,207 @@
package models
import (
"encoding/xml"
"reflect"
"testing"
)
func TestSystemTimeoutXML(t *testing.T) {
for _, enabled := range []bool{true, false} {
input := `<systemtimeout><powersaving_enabled>` + map[bool]string{true: "true", false: "false"}[enabled] + `</powersaving_enabled></systemtimeout>`
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{
`<systemtimeout/>`,
`<systemtimeout><powersaving_enabled>maybe</powersaving_enabled></systemtimeout>`,
`<wrong><powersaving_enabled>true</powersaving_enabled></wrong>`,
} {
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 := `<rebroadcastlatencymode mode="SYNC_TO_ZONE" controllable="true"></rebroadcastlatencymode>`
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 := `<rebroadcastlatencymode mode="SYNC_TO_ROOM"></rebroadcastlatencymode>`; string(got) != want {
t.Fatalf("xml.Marshal() = %q, want %q", got, want)
}
}
func TestRebroadcastLatencyModeValidation(t *testing.T) {
for _, input := range []string{
`<rebroadcastlatencymode controllable="true"/>`,
`<rebroadcastlatencymode mode="SYNC_TO_ROOM"/>`,
`<rebroadcastlatencymode mode="OTHER" controllable="true"/>`,
`<rebroadcastlatencymode mode="SYNC_TO_ROOM" controllable="maybe"/>`,
} {
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
}{
{`<sysLanguage>15</sysLanguage>`, LanguageCzech},
{`<sysLanguage>99</sysLanguage>`, 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 := `<sysLanguage>3</sysLanguage>`; 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(`<sysLanguage/>`), &missing); err == nil {
t.Error("xml.Unmarshal() unexpectedly accepted a missing language code")
}
var malformed SystemLanguage
if err := xml.Unmarshal([]byte(`<sysLanguage>English</sysLanguage>`), &malformed); err == nil {
t.Error("xml.Unmarshal() unexpectedly accepted a non-integer language")
}
}
func TestBluetoothInfoXML(t *testing.T) {
input := `<BluetoothInfo BluetoothMACAddress="AABBCCDDEEFF"></BluetoothInfo>`
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: `<ContentItem source="AUX" sourceAccount="AUX1"><itemName>Turntable</itemName></ContentItem>`,
},
{
request: SourceRenameRequest{Source: "BLUETOOTH", ItemName: "Phone"},
want: `<ContentItem source="BLUETOOTH"><itemName>Phone</itemName></ContentItem>`,
},
} {
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)
}
}
}
+6 -1
View File
@@ -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
}
+19 -1
View File
@@ -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",
+10 -4
View File
@@ -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
// <PairDeviceWithAccount> 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(`<sysLanguage>%d</sysLanguage>`, code)
return s.sendStep(ctx, "language", "POST", body)
}
+14
View File
@@ -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 {