mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-09-07 15:07:17 +00:00
feat(client): add capability-driven device settings
This commit is contained in:
committed by
Tobias Gesellchen
parent
369887f642
commit
0748e4042c
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user