mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
Compare commits
@@ -86,7 +86,7 @@ body:
|
||||
attributes:
|
||||
label: AfterTouch version
|
||||
description: Shown in the admin UI footer, or via the binary's `--version`.
|
||||
placeholder: "v0.111.2"
|
||||
placeholder: "v0.123.0"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -51,6 +51,6 @@ jobs:
|
||||
run: go build ./...
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
|
||||
- name: Upload Semgrep SARIF results
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
sarif_file: semgrep.sarif
|
||||
continue-on-error: true
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<p style="margin-top: -10px; font-style: italic; color: #666;">Bose SoundTouch Toolkit</p>
|
||||
|
||||
[](https://pkg.go.dev/github.com/gesellix/bose-soundtouch)
|
||||
[](https://goreportcard.com/report/github.com/gesellix/bose-soundtouch)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
> Independent project. **Not affiliated with, endorsed by, sponsored
|
||||
@@ -113,6 +112,7 @@ See the [API Reference](https://gesellix.github.io/Bose-SoundTouch/docs/referenc
|
||||
- **[SoundTouch Plus](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus)** (Todd Lucas) — Home Assistant integration; extensive undocumented API documentation
|
||||
- **[ÜberBöse API](https://github.com/julius-d/ueberboese-api)** (Julius) — API research and advanced endpoint discovery
|
||||
- **[Bose SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook)** (Adrian Böckenkamp) — `LD_PRELOAD` hooking for reverse engineering device internals
|
||||
- **[STR, SoundTouch Reborn](https://github.com/JRpersonal/streborn)** ([st-reborn.de](https://st-reborn.de)) — on-device agent plus desktop app; its published `iptables` REDIRECT technique is what makes AfterTouch's on-device install reachable over the LAN on co-processor chassis (see [Model Support Matrix](https://gesellix.github.io/Bose-SoundTouch/docs/reference/MODEL-SUPPORT-MATRIX/))
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -52,10 +52,12 @@ func setupCommand() *cli.Command {
|
||||
setupRemoteServicesCmd(),
|
||||
setupInstallCACmd(),
|
||||
setupMigrateCmd(),
|
||||
setupRevertCmd(),
|
||||
setupRebootCmd(),
|
||||
setupVerifyCmd(),
|
||||
setupPlanCmd(),
|
||||
setupPairCmd(),
|
||||
setupSyncCmd(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1013,6 +1015,116 @@ func promptBasicAuth() (string, string, error) {
|
||||
return user, string(pass), nil
|
||||
}
|
||||
|
||||
// setupSyncCmd wraps POST /api/setup/sync/{deviceId} — the same operation
|
||||
// as the web UI's Devices → Sync Data button. It only reads from the
|
||||
// speaker (presets, recents, sources) into AfterTouch's datastore; it never
|
||||
// writes anything back to the speaker. Useful for scripting or reproducing
|
||||
// what Sync does in isolation (see issue #614: Sync's own code cannot wipe
|
||||
// the speaker's preset table, since it never sends anything back).
|
||||
func setupSyncCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "sync",
|
||||
Usage: "Pull presets/recents/sources from the speaker into AfterTouch's datastore (same as the web UI's \"Sync Data\" button)",
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "service-url", Required: true, Usage: "AfterTouch base URL"},
|
||||
&cli.StringFlag{Name: "auth", Usage: "Basic-auth credentials for AfterTouch as user:pass (omit to be prompted on 401)"},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
cfg := GetClientConfig(c)
|
||||
serviceURL := strings.TrimRight(c.String("service-url"), "/")
|
||||
|
||||
if err := validateServiceURL(serviceURL); err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := CreateSoundTouchClient(cfg)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
deviceInfo, err := client.GetDeviceInfo()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to get device info from speaker: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if deviceInfo.DeviceID == "" {
|
||||
err := fmt.Errorf("speaker at %s did not report a DeviceID", cfg.Host)
|
||||
PrintError(err.Error())
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Syncing %s into AfterTouch", deviceInfo.DeviceID), cfg.Host, cfg.Port)
|
||||
|
||||
if err := postSetupSync(serviceURL, deviceInfo.DeviceID, c.String("auth")); err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Synced presets, recents, and sources for %s.", deviceInfo.DeviceID))
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// postSetupSync POSTs to AfterTouch's /api/setup/sync/{deviceId}, prompting
|
||||
// for basic-auth credentials on 401 (matches fetchCACert's pattern).
|
||||
func postSetupSync(serviceURL, deviceID, authFlag string) error {
|
||||
endpoint := fmt.Sprintf("%s/api/setup/sync/%s", serviceURL, deviceID)
|
||||
|
||||
doRequest := func(user, pass string) (*http.Response, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
user, pass := splitAuth(authFlag)
|
||||
|
||||
resp, err := doRequest(user, pass)
|
||||
if err != nil {
|
||||
return fmt.Errorf("POST %s: %w", endpoint, err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
_ = resp.Body.Close()
|
||||
|
||||
fmt.Printf("%s requires basic auth.\n", endpoint)
|
||||
|
||||
user, pass, err = promptBasicAuth()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err = doRequest(user, pass)
|
||||
if err != nil {
|
||||
return fmt.Errorf("POST %s (with auth): %w", endpoint, err)
|
||||
}
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("POST %s returned %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupMigrateCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "migrate",
|
||||
@@ -1023,6 +1135,10 @@ func setupMigrateCmd() *cli.Command {
|
||||
&cli.StringFlag{Name: "method", Value: string(setup.MigrationMethodTelnet), Usage: "telnet | hosts | resolv | xml"},
|
||||
&cli.StringFlag{Name: "proxy-url", Usage: "Optional upstream proxy URL (for --method=xml)"},
|
||||
&cli.BoolFlag{Name: "skip-preflight", Usage: "Skip the AfterTouch settings preflight (use when AfterTouch's settings endpoint is unreachable)"},
|
||||
&cli.StringFlag{Name: "marge-url", Usage: "Override margeServerUrl instead of deriving it from --service-url (e.g. to restore the original Bose cloud URL). Applies to --method=telnet and --method=xml"},
|
||||
&cli.StringFlag{Name: "stats-url", Usage: "Override statsServerUrl (telnet/xml)"},
|
||||
&cli.StringFlag{Name: "sw-update-url", Usage: "Override swUpdateUrl (telnet/xml)"},
|
||||
&cli.StringFlag{Name: "bmx-url", Usage: "Override bmxRegistryUrl (telnet/xml)"},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
cfg := GetClientConfig(c)
|
||||
@@ -1034,6 +1150,13 @@ func setupMigrateCmd() *cli.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
options := map[string]string{
|
||||
"marge_url": c.String("marge-url"),
|
||||
"stats_url": c.String("stats-url"),
|
||||
"sw_update_url": c.String("sw-update-url"),
|
||||
"bmx_url": c.String("bmx-url"),
|
||||
}
|
||||
|
||||
m := setup.NewManager(serviceURL, nil, nil)
|
||||
|
||||
// For DNS-redirect methods check that AfterTouch's DNS listener
|
||||
@@ -1057,7 +1180,7 @@ func setupMigrateCmd() *cli.Command {
|
||||
|
||||
fmt.Printf("Migrating %s → %s using method=%s\n", cfg.Host, serviceURL, method)
|
||||
|
||||
logs, err := m.MigrateSpeaker(cfg.Host, serviceURL, c.String("proxy-url"), nil, method)
|
||||
logs, err := m.MigrateSpeaker(cfg.Host, serviceURL, c.String("proxy-url"), options, method)
|
||||
if logs != "" {
|
||||
fmt.Print(logs)
|
||||
}
|
||||
@@ -1386,6 +1509,45 @@ func renderMigrationSummary(deviceIP, serviceURL string, s *setup.MigrationSumma
|
||||
}
|
||||
}
|
||||
|
||||
// setupRevertCmd wraps setup.Manager.RevertMigration — the same operation
|
||||
// as the web UI's "Revert to Defaults" button (Migrate tab). Restores
|
||||
// SoundTouchSdkPrivateCfg.xml, /etc/hosts, and /etc/resolv.conf from their
|
||||
// .original backups, removes the AfterTouch DNS-hook artifacts, and strips
|
||||
// just the AfterTouch-labeled cert out of the trust bundle. No --service-url
|
||||
// needed: everything it touches already lives on the speaker.
|
||||
//
|
||||
// Deliberately out of scope (matches the web UI button): SSH/remote_services
|
||||
// persistence (use `setup remote-services --remove`) and account pairing
|
||||
// (use `account unpair`) — see #614 self-test notes for the full checklist.
|
||||
func setupRevertCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "revert",
|
||||
Usage: "Undo a migration: restore SoundTouchSdkPrivateCfg.xml/hosts/resolv.conf from backups and remove the AfterTouch CA cert",
|
||||
Before: RequireHost,
|
||||
Action: func(c *cli.Context) error {
|
||||
cfg := GetClientConfig(c)
|
||||
m := setup.NewManager("", nil, nil)
|
||||
|
||||
fmt.Printf("Reverting migration on %s...\n", cfg.Host)
|
||||
|
||||
logs, err := m.RevertMigration(cfg.Host)
|
||||
if logs != "" {
|
||||
fmt.Print(logs)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess("Migration reverted. SSH access and account pairing are untouched by this — " +
|
||||
"see `setup remote-services --remove` and `account unpair` if you want those cleared too.")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func setupRebootCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "reboot",
|
||||
@@ -1919,6 +2081,17 @@ func runPairBare(c *cli.Context, deviceIP, accountID string) error {
|
||||
func runPairFull(c *cli.Context, deviceIP, accountID string) error {
|
||||
m := setup.NewManager(c.String("service-url"), nil, nil)
|
||||
|
||||
needed, status, err := m.PreflightInitPlan(deviceIP)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("preflight: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if !needed {
|
||||
PrintSuccess(fmt.Sprintf("Device already configured (status=%s) — nothing to do.", status))
|
||||
return nil
|
||||
}
|
||||
|
||||
plan := setup.InitPlan{
|
||||
DeviceIP: deviceIP,
|
||||
ServiceURL: c.String("service-url"),
|
||||
@@ -1932,7 +2105,7 @@ func runPairFull(c *cli.Context, deviceIP, accountID string) error {
|
||||
ctx, cancel := context.WithTimeout(c.Context, 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := m.ExecuteInitPlan(ctx, plan, func(e setup.StepEvent) {
|
||||
_, err = m.ExecuteInitPlan(ctx, plan, func(e setup.StepEvent) {
|
||||
switch e.Status {
|
||||
case setup.StatusOK:
|
||||
fmt.Printf("[%d] %s — ok\n", e.Kind, e.Name)
|
||||
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -43,6 +45,46 @@ func captureStdout(t *testing.T, fn func()) string {
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestPostSetupSync_PostsToDeviceScopedURL(t *testing.T) {
|
||||
var gotMethod, gotPath string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok": true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if err := postSetupSync(srv.URL, "DEVICEID01", ""); err != nil {
|
||||
t.Fatalf("postSetupSync: %v", err)
|
||||
}
|
||||
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", gotMethod)
|
||||
}
|
||||
|
||||
if want := "/api/setup/sync/DEVICEID01"; gotPath != want {
|
||||
t.Errorf("expected path %q, got %q", want, gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSetupSync_PropagatesServerError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "device not found", http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := postSetupSync(srv.URL, "DEVICEID01", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a 404 response")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "device not found") {
|
||||
t.Errorf("expected error to include server body, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSourceTable_AlignsColumnsAndDedupsDisplayName(t *testing.T) {
|
||||
items := []models.SourceItem{
|
||||
// displayName != account → kept as "AUX (AUX IN)"
|
||||
|
||||
+319
-262
@@ -224,6 +224,258 @@ func logBufferCapacityFromEnv(defaultCap int) int {
|
||||
return v
|
||||
}
|
||||
|
||||
// serviceFlags is the full flag/env-var surface for soundtouch-service.
|
||||
// Extracted to a package-level var (rather than inlined in main()'s
|
||||
// cli.App literal) so tests can build a real *cli.Context against the
|
||||
// exact same flags loadConfig reads, instead of hand-duplicating them.
|
||||
var serviceFlags = []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "port",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "HTTP port to bind the service to",
|
||||
Value: "8000",
|
||||
EnvVars: []string{"PORT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "bind",
|
||||
Usage: "Network interface to bind to",
|
||||
EnvVars: []string{"BIND_ADDR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "data-dir",
|
||||
Usage: "Directory for persistent data",
|
||||
Value: "data",
|
||||
EnvVars: []string{"DATA_DIR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "server-url",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "External URL of this service",
|
||||
EnvVars: []string{"SERVER_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "deployment-mode",
|
||||
Usage: "Where this service runs: on-device, private-network, or public-network " +
|
||||
"- informs the server-url fallback when --server-url isn't set",
|
||||
EnvVars: []string{"DEPLOYMENT_MODE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "https-port",
|
||||
Usage: "HTTPS port to bind the service to",
|
||||
Value: "8443",
|
||||
EnvVars: []string{"HTTPS_PORT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "https-server-url",
|
||||
Aliases: []string{"S"},
|
||||
Usage: "External HTTPS URL",
|
||||
EnvVars: []string{"HTTPS_SERVER_URL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "redact-logs",
|
||||
Usage: "Redact sensitive data in proxy logs",
|
||||
Value: true,
|
||||
EnvVars: []string{"REDACT_PROXY_LOGS"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "log-bodies",
|
||||
Usage: "Log full request/response bodies",
|
||||
EnvVars: []string{"LOG_PROXY_BODY"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "record-interactions",
|
||||
Usage: "Record HTTP interactions to disk",
|
||||
Value: true,
|
||||
EnvVars: []string{"RECORD_INTERACTIONS"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "discovery-enabled",
|
||||
Usage: "Enable periodic device discovery",
|
||||
Value: true,
|
||||
EnvVars: []string{"DISCOVERY_ENABLED"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "discovery-interval",
|
||||
Usage: "Device discovery interval",
|
||||
Value: "5m",
|
||||
EnvVars: []string{"DISCOVERY_INTERVAL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "update-check-enabled",
|
||||
Usage: "Periodically check GitHub for a newer release (opt-in; the only network call this makes beyond speaker/provider traffic)",
|
||||
Value: false,
|
||||
EnvVars: []string{"UPDATE_CHECK_ENABLED"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "update-check-interval",
|
||||
Usage: "Update check interval",
|
||||
Value: "24h",
|
||||
EnvVars: []string{"UPDATE_CHECK_INTERVAL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "dns-discovery",
|
||||
Usage: "Enable DNS discovery server",
|
||||
EnvVars: []string{"ENABLE_DNS_DISCOVERY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dns-upstream",
|
||||
Usage: "Upstream DNS server(s) for non-Bose queries (comma-separated). If empty, /etc/resolv.conf is used.",
|
||||
Value: "",
|
||||
EnvVars: []string{"DNS_UPSTREAM"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dns-bind",
|
||||
Usage: "Bind address for the DNS discovery server",
|
||||
Value: ":53",
|
||||
EnvVars: []string{"DNS_BIND_ADDR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-client-id",
|
||||
Usage: "Spotify OAuth client ID",
|
||||
EnvVars: []string{"SPOTIFY_CLIENT_ID"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-client-secret",
|
||||
Usage: "Spotify OAuth client secret",
|
||||
EnvVars: []string{"SPOTIFY_CLIENT_SECRET"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-redirect-uri",
|
||||
Usage: "Spotify OAuth redirect URI (defaults to <server-url>/mgmt/spotify/callback)",
|
||||
EnvVars: []string{"SPOTIFY_REDIRECT_URI"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-token-url",
|
||||
Usage: "Spotify OAuth token URL (for testing)",
|
||||
EnvVars: []string{"SPOTIFY_TOKEN_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-api-base",
|
||||
Usage: "Spotify API base URL (for testing)",
|
||||
EnvVars: []string{"SPOTIFY_API_BASE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-client-id",
|
||||
Usage: "Amazon LWA OAuth client ID",
|
||||
EnvVars: []string{"AMAZON_CLIENT_ID"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-client-secret",
|
||||
Usage: "Amazon LWA OAuth client secret",
|
||||
EnvVars: []string{"AMAZON_CLIENT_SECRET"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-redirect-uri",
|
||||
Usage: "Amazon LWA OAuth redirect URI (defaults to <server-url>/mgmt/amazon/callback)",
|
||||
EnvVars: []string{"AMAZON_REDIRECT_URI"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-token-url",
|
||||
Usage: "Amazon LWA token URL (for testing)",
|
||||
EnvVars: []string{"AMAZON_TOKEN_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-profile-url",
|
||||
Usage: "Amazon LWA profile URL (for testing)",
|
||||
EnvVars: []string{"AMAZON_PROFILE_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tunein-opml-url",
|
||||
Usage: "TuneIn OPML base URL, covering Tune.ashx/describe.ashx/navigate (for testing / local mock; defaults to opml.radiotime.com)",
|
||||
EnvVars: []string{"TUNEIN_OPML_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tunein-api-url",
|
||||
Usage: "TuneIn API base URL, covering search and profile contents (for testing / local mock; defaults to api.radiotime.com)",
|
||||
EnvVars: []string{"TUNEIN_API_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-provider",
|
||||
Usage: "Text-to-speech provider: 'translate' (Google Translate, no credentials, default) or 'google-cloud' (Google Cloud TTS, needs an API key). Empty falls back to translate; leave unset to let a value saved in the settings UI take effect",
|
||||
EnvVars: []string{"TTS_PROVIDER"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-google-api-key",
|
||||
Usage: "Google Cloud Text-to-Speech API key (required when --tts-provider=google-cloud)",
|
||||
EnvVars: []string{"TTS_GOOGLE_API_KEY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-google-endpoint",
|
||||
Usage: "Google Cloud TTS synthesize endpoint override (for testing)",
|
||||
EnvVars: []string{"TTS_GOOGLE_ENDPOINT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-language",
|
||||
Usage: "Default TTS language code. Provider-specific: 'EN'/'DE' for translate, BCP-47 like 'en-US' for google-cloud",
|
||||
EnvVars: []string{"TTS_LANGUAGE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-voice",
|
||||
Usage: "Default Google Cloud TTS voice name (e.g. en-US-Neural2-C); ignored by the translate provider",
|
||||
EnvVars: []string{"TTS_VOICE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-app-key",
|
||||
Usage: "Bose /speaker app_key used to play TTS notifications on speakers",
|
||||
EnvVars: []string{"TTS_APP_KEY"},
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "tts-volume",
|
||||
Usage: "Default TTS playback volume (0-100, 0 = keep current volume)",
|
||||
Value: 0,
|
||||
EnvVars: []string{"TTS_VOLUME"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mgmt-username",
|
||||
Usage: "Management API username for HTTP Basic Auth",
|
||||
Value: "admin",
|
||||
EnvVars: []string{"MGMT_USERNAME"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mgmt-password",
|
||||
Usage: "Management API password for HTTP Basic Auth",
|
||||
Value: "change_me!",
|
||||
EnvVars: []string{"MGMT_PASSWORD"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "base-url",
|
||||
Usage: "External base URL for OAuth callbacks behind reverse proxy",
|
||||
EnvVars: []string{"BASE_URL"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "internal-paths",
|
||||
Usage: "Paths for internal requests (comma-separated or multiple flags)",
|
||||
EnvVars: []string{"INTERNAL_PATHS"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "tls-extra-host",
|
||||
Usage: "Additional DNS name or IP to include in the server TLS certificate SAN list (repeatable)",
|
||||
EnvVars: []string{"TLS_EXTRA_HOST"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migration-enabled",
|
||||
Usage: "Enable device directory migration from serial to MAC-based structure",
|
||||
Value: true,
|
||||
EnvVars: []string{"MIGRATION_ENABLED"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migration-dry-run",
|
||||
Usage: "Log what would be migrated without actually doing it",
|
||||
EnvVars: []string{"MIGRATION_DRY_RUN"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "stockholm-dir",
|
||||
Usage: "Path to the extracted Stockholm frontend directory (enables Stockholm UI when set)",
|
||||
EnvVars: []string{"STOCKHOLM_DIR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "stockholm-base-path",
|
||||
Usage: "URL prefix under which the Stockholm UI is served (e.g. /stockholm). Empty serves at root.",
|
||||
Value: "/stockholm",
|
||||
EnvVars: []string{"STOCKHOLM_BASE_PATH"},
|
||||
},
|
||||
}
|
||||
|
||||
func main() {
|
||||
updateBuildInfo()
|
||||
|
||||
@@ -250,249 +502,13 @@ func main() {
|
||||
Name: "Tobias Gesellchen, and the Bose-SoundTouch Contributors",
|
||||
},
|
||||
},
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "port",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "HTTP port to bind the service to",
|
||||
Value: "8000",
|
||||
EnvVars: []string{"PORT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "bind",
|
||||
Usage: "Network interface to bind to",
|
||||
EnvVars: []string{"BIND_ADDR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "data-dir",
|
||||
Usage: "Directory for persistent data",
|
||||
Value: "data",
|
||||
EnvVars: []string{"DATA_DIR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "server-url",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "External URL of this service",
|
||||
EnvVars: []string{"SERVER_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "https-port",
|
||||
Usage: "HTTPS port to bind the service to",
|
||||
Value: "8443",
|
||||
EnvVars: []string{"HTTPS_PORT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "https-server-url",
|
||||
Aliases: []string{"S"},
|
||||
Usage: "External HTTPS URL",
|
||||
EnvVars: []string{"HTTPS_SERVER_URL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "redact-logs",
|
||||
Usage: "Redact sensitive data in proxy logs",
|
||||
Value: true,
|
||||
EnvVars: []string{"REDACT_PROXY_LOGS"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "log-bodies",
|
||||
Usage: "Log full request/response bodies",
|
||||
EnvVars: []string{"LOG_PROXY_BODY"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "record-interactions",
|
||||
Usage: "Record HTTP interactions to disk",
|
||||
Value: true,
|
||||
EnvVars: []string{"RECORD_INTERACTIONS"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "discovery-enabled",
|
||||
Usage: "Enable periodic device discovery",
|
||||
Value: true,
|
||||
EnvVars: []string{"DISCOVERY_ENABLED"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "discovery-interval",
|
||||
Usage: "Device discovery interval",
|
||||
Value: "5m",
|
||||
EnvVars: []string{"DISCOVERY_INTERVAL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "update-check-enabled",
|
||||
Usage: "Periodically check GitHub for a newer release (opt-in; the only network call this makes beyond speaker/provider traffic)",
|
||||
Value: false,
|
||||
EnvVars: []string{"UPDATE_CHECK_ENABLED"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "update-check-interval",
|
||||
Usage: "Update check interval",
|
||||
Value: "24h",
|
||||
EnvVars: []string{"UPDATE_CHECK_INTERVAL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "dns-discovery",
|
||||
Usage: "Enable DNS discovery server",
|
||||
EnvVars: []string{"ENABLE_DNS_DISCOVERY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dns-upstream",
|
||||
Usage: "Upstream DNS server(s) for non-Bose queries (comma-separated). If empty, /etc/resolv.conf is used.",
|
||||
Value: "",
|
||||
EnvVars: []string{"DNS_UPSTREAM"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dns-bind",
|
||||
Usage: "Bind address for the DNS discovery server",
|
||||
Value: ":53",
|
||||
EnvVars: []string{"DNS_BIND_ADDR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-client-id",
|
||||
Usage: "Spotify OAuth client ID",
|
||||
EnvVars: []string{"SPOTIFY_CLIENT_ID"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-client-secret",
|
||||
Usage: "Spotify OAuth client secret",
|
||||
EnvVars: []string{"SPOTIFY_CLIENT_SECRET"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-redirect-uri",
|
||||
Usage: "Spotify OAuth redirect URI (defaults to <server-url>/mgmt/spotify/callback)",
|
||||
EnvVars: []string{"SPOTIFY_REDIRECT_URI"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-token-url",
|
||||
Usage: "Spotify OAuth token URL (for testing)",
|
||||
EnvVars: []string{"SPOTIFY_TOKEN_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-api-base",
|
||||
Usage: "Spotify API base URL (for testing)",
|
||||
EnvVars: []string{"SPOTIFY_API_BASE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-client-id",
|
||||
Usage: "Amazon LWA OAuth client ID",
|
||||
EnvVars: []string{"AMAZON_CLIENT_ID"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-client-secret",
|
||||
Usage: "Amazon LWA OAuth client secret",
|
||||
EnvVars: []string{"AMAZON_CLIENT_SECRET"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-redirect-uri",
|
||||
Usage: "Amazon LWA OAuth redirect URI (defaults to <server-url>/mgmt/amazon/callback)",
|
||||
EnvVars: []string{"AMAZON_REDIRECT_URI"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-token-url",
|
||||
Usage: "Amazon LWA token URL (for testing)",
|
||||
EnvVars: []string{"AMAZON_TOKEN_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "amazon-profile-url",
|
||||
Usage: "Amazon LWA profile URL (for testing)",
|
||||
EnvVars: []string{"AMAZON_PROFILE_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tunein-opml-url",
|
||||
Usage: "TuneIn OPML base URL, covering Tune.ashx/describe.ashx/navigate (for testing / local mock; defaults to opml.radiotime.com)",
|
||||
EnvVars: []string{"TUNEIN_OPML_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tunein-api-url",
|
||||
Usage: "TuneIn API base URL, covering search and profile contents (for testing / local mock; defaults to api.radiotime.com)",
|
||||
EnvVars: []string{"TUNEIN_API_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-provider",
|
||||
Usage: "Text-to-speech provider: 'translate' (Google Translate, no credentials, default) or 'google-cloud' (Google Cloud TTS, needs an API key). Empty falls back to translate; leave unset to let a value saved in the settings UI take effect",
|
||||
EnvVars: []string{"TTS_PROVIDER"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-google-api-key",
|
||||
Usage: "Google Cloud Text-to-Speech API key (required when --tts-provider=google-cloud)",
|
||||
EnvVars: []string{"TTS_GOOGLE_API_KEY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-google-endpoint",
|
||||
Usage: "Google Cloud TTS synthesize endpoint override (for testing)",
|
||||
EnvVars: []string{"TTS_GOOGLE_ENDPOINT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-language",
|
||||
Usage: "Default TTS language code. Provider-specific: 'EN'/'DE' for translate, BCP-47 like 'en-US' for google-cloud",
|
||||
EnvVars: []string{"TTS_LANGUAGE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-voice",
|
||||
Usage: "Default Google Cloud TTS voice name (e.g. en-US-Neural2-C); ignored by the translate provider",
|
||||
EnvVars: []string{"TTS_VOICE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-app-key",
|
||||
Usage: "Bose /speaker app_key used to play TTS notifications on speakers",
|
||||
EnvVars: []string{"TTS_APP_KEY"},
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "tts-volume",
|
||||
Usage: "Default TTS playback volume (0-100, 0 = keep current volume)",
|
||||
Value: 0,
|
||||
EnvVars: []string{"TTS_VOLUME"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mgmt-username",
|
||||
Usage: "Management API username for HTTP Basic Auth",
|
||||
Value: "admin",
|
||||
EnvVars: []string{"MGMT_USERNAME"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mgmt-password",
|
||||
Usage: "Management API password for HTTP Basic Auth",
|
||||
Value: "change_me!",
|
||||
EnvVars: []string{"MGMT_PASSWORD"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "base-url",
|
||||
Usage: "External base URL for OAuth callbacks behind reverse proxy",
|
||||
EnvVars: []string{"BASE_URL"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "internal-paths",
|
||||
Usage: "Paths for internal requests (comma-separated or multiple flags)",
|
||||
EnvVars: []string{"INTERNAL_PATHS"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "tls-extra-host",
|
||||
Usage: "Additional DNS name or IP to include in the server TLS certificate SAN list (repeatable)",
|
||||
EnvVars: []string{"TLS_EXTRA_HOST"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migration-enabled",
|
||||
Usage: "Enable device directory migration from serial to MAC-based structure",
|
||||
Value: true,
|
||||
EnvVars: []string{"MIGRATION_ENABLED"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migration-dry-run",
|
||||
Usage: "Log what would be migrated without actually doing it",
|
||||
EnvVars: []string{"MIGRATION_DRY_RUN"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "stockholm-dir",
|
||||
Usage: "Path to the extracted Stockholm frontend directory (enables Stockholm UI when set)",
|
||||
EnvVars: []string{"STOCKHOLM_DIR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "stockholm-base-path",
|
||||
Usage: "URL prefix under which the Stockholm UI is served (e.g. /stockholm). Empty serves at root.",
|
||||
Value: "/stockholm",
|
||||
EnvVars: []string{"STOCKHOLM_BASE_PATH"},
|
||||
},
|
||||
},
|
||||
Flags: serviceFlags,
|
||||
Action: func(c *cli.Context) error {
|
||||
config := loadConfig(c)
|
||||
config, err := loadConfig(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ds := initDataStore(config.dataDir)
|
||||
|
||||
// Detect a genuinely fresh data dir by the ABSENCE of settings.json,
|
||||
@@ -517,13 +533,11 @@ func main() {
|
||||
persisted = createDefaultSettings(ds, config)
|
||||
}
|
||||
|
||||
// Recalculate domains if settings changed
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname, config.tlsExtraHosts)
|
||||
// Recalculate domains if settings changed. Reuses the same mode-aware
|
||||
// fallback host loadConfig already resolved, rather than a raw
|
||||
// os.Hostname() call, so an on-device install doesn't leak its
|
||||
// unresolvable variant codename back in here (see issue #546).
|
||||
config.domains = getDomains(config.serverURL, config.httpsServerURL, config.hostname, config.tlsExtraHosts)
|
||||
|
||||
cm := initCertificateManager(config.dataDir, config.hostname)
|
||||
sm := setup.NewManager(config.serverURL, ds, cm)
|
||||
@@ -752,7 +766,32 @@ type serviceConfig struct {
|
||||
stockholmBasePath string
|
||||
}
|
||||
|
||||
func loadConfig(c *cli.Context) serviceConfig {
|
||||
// resolveFallbackHost picks the host used to guess a server URL when
|
||||
// --server-url/SERVER_URL isn't set, based on where this service runs.
|
||||
// On-device (running on the speaker's own Linux) is the one case where
|
||||
// os.Hostname() is guaranteed useless: it returns the speaker's internal
|
||||
// variant codename (e.g. "spotty", "mojo"), which nothing can resolve, not
|
||||
// even the speaker itself (see issue #546). warnOnUse reports whether
|
||||
// falling back to the returned host is risky enough to warrant a startup
|
||||
// warning.
|
||||
func resolveFallbackHost(deploymentMode string) (host string, warnOnUse bool) {
|
||||
switch deploymentMode {
|
||||
case "on-device":
|
||||
return "localhost", false
|
||||
case "public-network":
|
||||
// Caller must refuse to guess a publicly reachable address.
|
||||
return "", false
|
||||
default: // "private-network", "", or any unrecognized value: today's behavior.
|
||||
h, _ := os.Hostname()
|
||||
if h == "" {
|
||||
h = "localhost"
|
||||
}
|
||||
|
||||
return strings.ToLower(h), true
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig(c *cli.Context) (serviceConfig, error) {
|
||||
port := c.String("port")
|
||||
bindAddr := c.String("bind")
|
||||
|
||||
@@ -763,16 +802,23 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
|
||||
dataDir := c.String("data-dir")
|
||||
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
hostname = strings.ToLower(hostname)
|
||||
deploymentMode := c.String("deployment-mode")
|
||||
fallbackHost, warnOnFallback := resolveFallbackHost(deploymentMode)
|
||||
|
||||
serverURL := c.String("server-url")
|
||||
if serverURL == "" {
|
||||
serverURL = "http://" + hostname + ":" + port
|
||||
if deploymentMode == "public-network" {
|
||||
return serviceConfig{}, fmt.Errorf(
|
||||
"--server-url (or SERVER_URL) is required when --deployment-mode=public-network; refusing to guess a public address")
|
||||
}
|
||||
|
||||
serverURL = "http://" + fallbackHost + ":" + port
|
||||
|
||||
if warnOnFallback {
|
||||
log.Printf("Warning: --server-url not set; defaulting to %s using this host's own hostname. "+
|
||||
"If your SoundTouch speakers can't reach this address, set --server-url/SERVER_URL explicitly, "+
|
||||
"or pass --deployment-mode=on-device if this runs on the speaker itself.", sanitizeLog(serverURL))
|
||||
}
|
||||
}
|
||||
// Strip a trailing slash so it cannot leak into the BMX registry base or the
|
||||
// margeServerUrl/bmxRegistryUrl pushed to speakers during migration.
|
||||
@@ -787,14 +833,14 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
|
||||
// The HTTPS URL is an override (from the flag/env); when empty it is
|
||||
// derived from serverURL + https port so one setting (Target Domain)
|
||||
// drives both. httpsDefaultURL is the hostname-based fallback used
|
||||
// drives both. httpsDefaultURL is the same mode-aware fallback used
|
||||
// before a Target Domain is configured.
|
||||
httpsOverride := c.String("https-server-url")
|
||||
httpsDefaultURL := "https://" + hostname + ":" + httpsPort
|
||||
httpsDefaultURL := "https://" + fallbackHost + ":" + httpsPort
|
||||
httpsServerURL := handlers.DeriveHTTPSURL(serverURL, httpsOverride, httpsPort, httpsDefaultURL)
|
||||
|
||||
tlsExtraHosts := c.StringSlice("tls-extra-host")
|
||||
domains := getDomains(serverURL, httpsServerURL, hostname, tlsExtraHosts)
|
||||
domains := getDomains(serverURL, httpsServerURL, fallbackHost, tlsExtraHosts)
|
||||
|
||||
redact := c.Bool("redact-logs")
|
||||
logBody := c.Bool("log-bodies")
|
||||
@@ -856,7 +902,7 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
bindAddr: bindAddr,
|
||||
addr: addr,
|
||||
dataDir: dataDir,
|
||||
hostname: hostname,
|
||||
hostname: fallbackHost,
|
||||
serverURL: serverURL,
|
||||
httpsServerURL: httpsServerURL,
|
||||
httpsOverride: httpsOverride,
|
||||
@@ -901,7 +947,7 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
migrationDryRun: migrationDryRun,
|
||||
stockholmDir: stockholmDir,
|
||||
stockholmBasePath: stockholmBasePath,
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getDomains(serverURL, httpsServerURL, hostname string, extraHosts []string) []string {
|
||||
@@ -1425,6 +1471,17 @@ func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, d
|
||||
return err
|
||||
}
|
||||
|
||||
// Opt-in (#622): hand-edit settings.json's auto_resume_on_source_disconnect
|
||||
// to enable. Read fresh per drop so toggling it applies without a restart.
|
||||
webApp.AutoResumeOnSourceDisconnect = func() bool {
|
||||
settings, err := ds.GetSettings()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return settings.AutoResumeOnSourceDisconnect
|
||||
}
|
||||
|
||||
// Keep the UI registry live as the service discovers or devices are added.
|
||||
server.SetDevicesChangedHook(func() {
|
||||
webApp.SeedExtraDevices()
|
||||
|
||||
@@ -1,13 +1,147 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// newTestServiceContext builds a real *cli.Context against serviceFlags (the
|
||||
// exact flags soundtouch-service registers), so loadConfig tests exercise the
|
||||
// same parsing/env-var wiring production code does, instead of a hand-rolled
|
||||
// stand-in that could silently drift from it.
|
||||
func newTestServiceContext(t *testing.T, args ...string) *cli.Context {
|
||||
t.Helper()
|
||||
|
||||
app := &cli.App{Flags: serviceFlags}
|
||||
set := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
|
||||
for _, f := range serviceFlags {
|
||||
if err := f.Apply(set); err != nil {
|
||||
t.Fatalf("apply flag %v: %v", f.Names(), err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := set.Parse(args); err != nil {
|
||||
t.Fatalf("parse args %v: %v", args, err)
|
||||
}
|
||||
|
||||
return cli.NewContext(app, set, nil)
|
||||
}
|
||||
|
||||
func TestResolveFallbackHost(t *testing.T) {
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
hostname = strings.ToLower(hostname)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
deploymentMode string
|
||||
wantHost string
|
||||
wantWarn bool
|
||||
}{
|
||||
{"on-device uses localhost, no warning", "on-device", "localhost", false},
|
||||
{"public-network returns no fallback, no warning (caller must fail fast)", "public-network", "", false},
|
||||
{"private-network uses this host's own hostname, with warning", "private-network", hostname, true},
|
||||
{"unset/legacy behaves like private-network", "", hostname, true},
|
||||
{"unrecognized mode behaves like private-network", "some-typo", hostname, true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotHost, gotWarn := resolveFallbackHost(tc.deploymentMode)
|
||||
if gotHost != tc.wantHost {
|
||||
t.Errorf("host: got %q, want %q", gotHost, tc.wantHost)
|
||||
}
|
||||
|
||||
if gotWarn != tc.wantWarn {
|
||||
t.Errorf("warnOnUse: got %v, want %v", gotWarn, tc.wantWarn)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_DeploymentMode(t *testing.T) {
|
||||
t.Run("on-device with no --server-url defaults to localhost", func(t *testing.T) {
|
||||
config, err := loadConfig(newTestServiceContext(t, "--deployment-mode=on-device", "--port=8000"))
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if config.serverURL != "http://localhost:8000" {
|
||||
t.Errorf("serverURL: got %q, want %q", config.serverURL, "http://localhost:8000")
|
||||
}
|
||||
|
||||
if config.httpsDefaultURL != "https://localhost:8443" {
|
||||
t.Errorf("httpsDefaultURL: got %q, want %q", config.httpsDefaultURL, "https://localhost:8443")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("public-network with no --server-url fails fast instead of guessing", func(t *testing.T) {
|
||||
_, err := loadConfig(newTestServiceContext(t, "--deployment-mode=public-network"))
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "public-network") {
|
||||
t.Errorf("expected error to mention public-network, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("public-network with an explicit --server-url succeeds", func(t *testing.T) {
|
||||
config, err := loadConfig(newTestServiceContext(t,
|
||||
"--deployment-mode=public-network", "--server-url=https://soundtouch.example.com"))
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if config.serverURL != "https://soundtouch.example.com" {
|
||||
t.Errorf("serverURL: got %q, want %q", config.serverURL, "https://soundtouch.example.com")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unset deployment-mode with no --server-url keeps today's hostname fallback", func(t *testing.T) {
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
hostname = strings.ToLower(hostname)
|
||||
|
||||
config, err := loadConfig(newTestServiceContext(t, "--port=8000"))
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
want := "http://" + hostname + ":8000"
|
||||
if config.serverURL != want {
|
||||
t.Errorf("serverURL: got %q, want %q (legacy installs must keep working without --deployment-mode)", config.serverURL, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit --server-url always wins regardless of deployment-mode", func(t *testing.T) {
|
||||
for _, mode := range []string{"", "on-device", "private-network", "public-network"} {
|
||||
config, err := loadConfig(newTestServiceContext(t,
|
||||
"--deployment-mode="+mode, "--server-url=http://198.51.100.7:8000"))
|
||||
if err != nil {
|
||||
t.Fatalf("mode %q: loadConfig: unexpected error: %v", mode, err)
|
||||
}
|
||||
|
||||
if config.serverURL != "http://198.51.100.7:8000" {
|
||||
t.Errorf("mode %q: serverURL: got %q, want explicit override unchanged", mode, config.serverURL)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyPersistedSettings(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "main-test")
|
||||
if err != nil {
|
||||
|
||||
@@ -112,6 +112,14 @@ Factory-reset the same speaker again and run the full state machine — the same
|
||||
|
||||
This drives `setup.Manager.ExecuteInitPlan` with `SkipURLRewrite=true`, which runs:
|
||||
|
||||
> **Update (#615):** `--mode=full` now preflights via `Manager.PreflightInitPlan`
|
||||
> before opening the WebSocket — it checks `/supportedURLs` for
|
||||
> `/setMargeAccount` and requires `/soundTouchConfigurationStatus` to read
|
||||
> `SOUNDTOUCH_NOT_CONFIGURED`, and no-ops on an already-configured device.
|
||||
> A freshly factory-reset speaker (as in this experiment) reports
|
||||
> `SOUNDTOUCH_NOT_CONFIGURED`, so the preflight passes through unchanged;
|
||||
> see `docs/content/docs/reference/DEVICE-PAIRING-FLOW.md`.
|
||||
|
||||
```
|
||||
SETUP_START
|
||||
SETUP_IDENTIFY_DEVICE_ENTER
|
||||
|
||||
@@ -592,6 +592,9 @@ soundtouch-cli --host <device> account remove-amazon --user <USER>
|
||||
soundtouch-cli --host <device> account remove-deezer --user <USER>
|
||||
soundtouch-cli --host <device> account remove-iheart --user <USER>
|
||||
soundtouch-cli --host <device> account remove-nas --user <GUID/0> [--name <NAME>]
|
||||
|
||||
# Unpair the device from its Marge cloud account entirely
|
||||
soundtouch-cli --host <device> account unpair
|
||||
```
|
||||
|
||||
**Supported Services:**
|
||||
@@ -648,6 +651,11 @@ soundtouch-cli --host 192.0.2.10 account remove \
|
||||
- Network music libraries (STORED_MUSIC) don't require passwords, only the UPnP server GUID
|
||||
- After adding an account, use `source list` to verify it appears as available
|
||||
- Some services may require additional authentication steps through their mobile apps
|
||||
- `account unpair` is different from the above: it sends `UnPairDeviceWithAccount`
|
||||
over the speaker's own local WebSocket to remove its **Marge cloud account**
|
||||
pairing entirely (`margeAccountUUID`), not a single streaming-service login.
|
||||
See `setup revert` for the related "undo a migration" operation, which
|
||||
deliberately does *not* call this — the two are separate steps.
|
||||
|
||||
### Bass Control
|
||||
|
||||
@@ -1183,6 +1191,264 @@ https://github.com/gesellix/Bose-SoundTouch/releases/tag/v1.3.0
|
||||
- If the running binary isn't a released version (e.g. a dev build),
|
||||
the command reports that and skips the comparison.
|
||||
|
||||
### Setup & Migration
|
||||
|
||||
The `setup <subcommand>` group provisions a speaker end-to-end: enabling
|
||||
SSH, factory-reset + Wi-Fi re-provisioning, pointing it at AfterTouch, CA
|
||||
trust, account pairing, reverting, and one-shot data sync. Each subcommand
|
||||
wraps an existing `pkg/service/setup` helper directly — there's no separate
|
||||
business logic in the CLI layer. Manual provisioning-loop background:
|
||||
[docs/analysis/SETUP-WEBSOCKET-EXPERIMENT.md](../analysis/SETUP-WEBSOCKET-EXPERIMENT.md)
|
||||
and [Device Initial Setup](DEVICE-INITIAL-SETUP.md).
|
||||
|
||||
#### `setup inspect`
|
||||
|
||||
Non-destructive snapshot of the speaker: identity, pairing state, Wi-Fi,
|
||||
sources, presets, and (with `--telnet`) the runtime URL configuration via
|
||||
`getpdo`. Good first command to run against an unfamiliar speaker.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup inspect
|
||||
soundtouch-cli --host <device> setup inspect --telnet # also reads runtime URLs (slower)
|
||||
```
|
||||
|
||||
#### `setup ssh-check`
|
||||
|
||||
Probes whether port 22 is reachable. On failure, prints the `enable-ssh`
|
||||
suggestion and the USB-stick fallback procedure.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup ssh-check [--timeout 3s]
|
||||
```
|
||||
|
||||
#### `setup enable-ssh`
|
||||
|
||||
Bootstraps SSH on a speaker with no prior access, via the port-17000
|
||||
`envswitch` trick (#471) — no USB stick needed. Auto-pairs an unpaired
|
||||
(factory-reset) device first by default (the injection needs something to
|
||||
poll), waits for `:22`, and persists the `remote_services` marker so SSH
|
||||
survives a reboot.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup enable-ssh
|
||||
soundtouch-cli --host <device> setup enable-ssh --service-url https://192.0.2.10:8443
|
||||
```
|
||||
|
||||
Flags:
|
||||
- `--service-url` — optional; only the vehicle for the injection, no live
|
||||
server required. Set the real URL later via `setup migrate`.
|
||||
- `--wait` (default `90s`) — how long to wait for `:22` after injection.
|
||||
- `--full-config` — for stubborn devices (ST Portable, CineMate 520) where
|
||||
the default injection is accepted but `sshd` never starts: writes all
|
||||
four config URLs (the #515 sequence) and reboots.
|
||||
- `--command-delay` — only affects `--full-config`; pause between its 6
|
||||
steps.
|
||||
- `--no-auto-pair` / `--account` — skip or control the automatic pairing
|
||||
check.
|
||||
- `--no-reset-urls` — skip restoring clean `boseurls` after SSH is up.
|
||||
- `--no-persist` — skip persisting `remote_services` (SSH won't survive a
|
||||
reboot).
|
||||
- `--authorized-key` — opt-in hardening: install an SSH public key instead
|
||||
of relying on the empty-password login.
|
||||
- `--close-17000` — opt-in hardening: firewall off port 17000 from the LAN
|
||||
(loopback access kept).
|
||||
|
||||
#### `setup remote-services`
|
||||
|
||||
Enables (default) or removes the `remote_services` SSH-enablement marker.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup remote-services # ensure it's present
|
||||
soundtouch-cli --host <device> setup remote-services --remove # disable SSH after next reboot
|
||||
```
|
||||
|
||||
#### `setup factory-reset`
|
||||
|
||||
Issues `sys factorydefault` over telnet — wipes account, presets, and
|
||||
Wi-Fi, and reboots the speaker into its own setup-mode AP. Prints the next
|
||||
steps (`wait-ap`, then `wifi-push`).
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup factory-reset
|
||||
```
|
||||
|
||||
> **Heads-up:** just before resetting, the speaker sends
|
||||
> `DELETE /streaming/account/{id}/device/{id}` to whatever `margeURL` is
|
||||
> *currently* configured. If that still points at `streaming.bose.com`
|
||||
> (not AfterTouch), AfterTouch keeps a stale datastore entry — migrate
|
||||
> first if you want a clean record.
|
||||
|
||||
#### `setup wait-ap`
|
||||
|
||||
Polls the speaker's setup-mode AP (default `192.0.2.1`) until `/info`
|
||||
responds, after a factory reset.
|
||||
|
||||
```bash
|
||||
soundtouch-cli setup wait-ap [--ap-host 192.0.2.1] [--interval 2s] [--timeout 5m]
|
||||
```
|
||||
|
||||
#### `setup wifi-push`
|
||||
|
||||
POSTs `AddWirelessProfile` to the speaker's setup-mode endpoint — pushes
|
||||
your home Wi-Fi credentials while connected to the speaker's AP.
|
||||
|
||||
```bash
|
||||
soundtouch-cli setup wifi-push --ssid="YourHomeSSID" --pass='your-password'
|
||||
```
|
||||
|
||||
Flags: `--security` (default `wpa_or_wpa2`), `--ap-host` (default
|
||||
`192.0.2.1`), `--request-timeout` (default `30s` — the speaker can be slow
|
||||
to ACK before tearing down AP mode; 10s often races).
|
||||
|
||||
#### `setup wait-online`
|
||||
|
||||
Polls mDNS until a speaker matching `--match` comes online on the home
|
||||
network — run this after switching back from the speaker's AP.
|
||||
|
||||
```bash
|
||||
soundtouch-cli setup wait-online --match=<last-6-hex-of-deviceID>
|
||||
```
|
||||
|
||||
`--match` is empty by default (first speaker seen); `--interval` (`3s`) and
|
||||
`--timeout` (`5m`) control the poll.
|
||||
|
||||
#### `setup install-ca`
|
||||
|
||||
Fetches AfterTouch's CA cert from `/api/setup/ca.crt` and injects it into
|
||||
the speaker's trust store via SSH.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup install-ca --service-url https://192.0.2.10:8443
|
||||
```
|
||||
|
||||
`--auth` (`user:pass`) supplies basic-auth credentials up front; omit it to
|
||||
be prompted interactively if the endpoint returns 401.
|
||||
|
||||
#### `setup migrate`
|
||||
|
||||
Applies a migration method to point the speaker at AfterTouch — the CLI
|
||||
equivalent of the web UI's Migrate tab.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup migrate --service-url http://192.0.2.10:8000 --method telnet
|
||||
```
|
||||
|
||||
`--method` is one of `telnet` (default) | `hosts` | `resolv` | `xml`.
|
||||
`--proxy-url` sets an optional upstream proxy (only used by `--method=xml`).
|
||||
`--skip-preflight` skips AfterTouch's settings preflight check (useful when
|
||||
that endpoint is unreachable).
|
||||
|
||||
`--marge-url`/`--stats-url`/`--sw-update-url`/`--bmx-url` override the
|
||||
corresponding field instead of deriving it from `--service-url` (applies to
|
||||
both `--method=telnet` and `--method=xml`). Useful beyond soundcork-style
|
||||
setups: e.g. pointing a speaker back at the **original Bose cloud URLs**
|
||||
without a full `setup revert` — telnet writes both the runtime and
|
||||
persisted layers in a single connection, no SSH or `.original` backup
|
||||
needed:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup migrate --method telnet \
|
||||
--service-url https://streaming.bose.com \
|
||||
--marge-url https://streaming.bose.com \
|
||||
--stats-url https://events.api.bosecm.com \
|
||||
--sw-update-url https://worldwide.bose.com/updates/soundtouch \
|
||||
--bmx-url https://content.api.bose.io/bmx/registry/v1/services
|
||||
```
|
||||
|
||||
#### `setup revert`
|
||||
|
||||
Undoes a migration — the CLI equivalent of the web UI's "Revert to
|
||||
Defaults" button. Restores `SoundTouchSdkPrivateCfg.xml`, `/etc/hosts`, and
|
||||
`/etc/resolv.conf` from their `.original` backups, removes the AfterTouch
|
||||
DNS-hook artifacts, and strips just the AfterTouch-labeled certificate out
|
||||
of the trust bundle. No `--service-url` needed — everything it touches
|
||||
already lives on the speaker.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup revert
|
||||
```
|
||||
|
||||
**Out of scope for this command** (matches the web UI button): SSH /
|
||||
`remote_services` persistence (use `setup remote-services --remove`) and
|
||||
account pairing (use `account unpair`) are untouched — revert them
|
||||
separately if you want a fully clean speaker.
|
||||
|
||||
#### `setup reboot`
|
||||
|
||||
Reboots the speaker — useful to force the envswitch parallel-persistence
|
||||
layer to apply after a migration.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup reboot [--method telnet|ssh]
|
||||
```
|
||||
|
||||
`--method` defaults to `telnet`, which works without SSH on modern
|
||||
firmware.
|
||||
|
||||
#### `setup verify`
|
||||
|
||||
Read-only status probe across every migration axis (transports, URL
|
||||
configuration, DNS interception, CA/TLS, pairing) — doubles as a preflight
|
||||
check before applying changes and a verification step afterward. Exits
|
||||
non-zero if nothing reports migrated, so it's usable as a CI gate.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup verify --service-url http://192.0.2.10:8000
|
||||
```
|
||||
|
||||
#### `setup plan`
|
||||
|
||||
Recommends the next setup/migration steps based on `inspect` + `verify`
|
||||
state — prints a ready-to-run command for each recommended step.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup plan --service-url http://192.0.2.10:8000
|
||||
soundtouch-cli --host <device> setup plan --service-url http://192.0.2.10:8000 --reset # plan a full factory-reset → Wi-Fi → migrate → pair flow
|
||||
```
|
||||
|
||||
`--wifi-ssid` overrides the SSID used for the `wifi-push` step in a reset
|
||||
plan (default: reuse the SSID `inspect` found). `--include-pair` (default
|
||||
`true`) can be disabled if you'll pair manually.
|
||||
|
||||
#### `setup pair`
|
||||
|
||||
Pairs the speaker with an account via the WebSocket `SETUP` state machine
|
||||
(`--mode=full`, matching the Bose app's own flow) or a minimal
|
||||
`setMargeAccount`-only call (`--mode=bare`, the same underlying call the
|
||||
Health tab's "empty margeAccountUUID" QuickFix uses).
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup pair --mode=full --account=1111111 --service-url http://192.0.2.10:8000
|
||||
soundtouch-cli --host <device> setup pair --mode=bare --account=1111111 --service-url http://192.0.2.10:8000
|
||||
```
|
||||
|
||||
`--account` empty generates a fresh 7-digit ID. `--name` sets the speaker
|
||||
name during pairing (empty keeps current). `--language` defaults to `2`
|
||||
(English). `--token` defaults to a built-in placeholder matching the Bose
|
||||
app's token shape.
|
||||
|
||||
`--mode=full` first reads `/supportedURLs` and `/soundTouchConfigurationStatus`
|
||||
and only runs the state machine when the device reports
|
||||
`SOUNDTOUCH_NOT_CONFIGURED` (see [#615](https://github.com/gesellix/Bose-SoundTouch/issues/615):
|
||||
a speaker can be reachable, named, and already account-paired yet still
|
||||
report `SOUNDTOUCH_NOT_CONFIGURED`, leaving the "install the Bose app"
|
||||
prompt on screen — only a full pass through the state machine clears it).
|
||||
An already-configured device is a no-op; an unsupported route or an
|
||||
unrecognised status value fails the command instead of guessing.
|
||||
|
||||
#### `setup sync`
|
||||
|
||||
Pulls presets, recents, and sources from the speaker into AfterTouch's
|
||||
datastore — the CLI equivalent of the web UI's Devices → Sync Data button.
|
||||
Read-only towards the speaker: it never writes anything back.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> setup sync --service-url http://192.0.2.10:8000
|
||||
```
|
||||
|
||||
`--auth` (`user:pass`) supplies basic-auth credentials up front; omit it to
|
||||
be prompted interactively if the endpoint returns 401.
|
||||
|
||||
## Common Usage Patterns
|
||||
|
||||
### Quick Device Setup
|
||||
|
||||
@@ -99,18 +99,28 @@ After factory restore the speaker enters setup mode automatically; no power-cycl
|
||||
|
||||
## 6. AP Mode Wi-Fi Provisioning via Console
|
||||
|
||||
When BLE is unavailable (e.g. when using an Android emulator), use AP mode to push Wi-Fi credentials from the Mac command line.
|
||||
When BLE is unavailable (e.g. when using an Android emulator), use AP mode to push Wi-Fi credentials from the command line. The HTTP steps below (6.2, 6.3) are OS-agnostic; only the Wi-Fi-network-switching commands (6.1, 6.4) are platform-specific — macOS is shown inline, with Linux and Windows equivalents alongside.
|
||||
|
||||
### 6.1 Connect Mac to Speaker AP
|
||||
### 6.1 Connect your machine to the Speaker AP
|
||||
|
||||
After factory reset the speaker broadcasts an SSID like `Bose SoundTouch XXXX`. Connect the Mac to it:
|
||||
After factory reset the speaker broadcasts an SSID like `Bose SoundTouch XXXX`. Connect to it:
|
||||
|
||||
```bash
|
||||
# List nearby SSIDs — use System Settings → Wi-Fi (the airport command was removed in macOS Sequoia+)
|
||||
# Connect (replace with actual SSID)
|
||||
# macOS — list nearby SSIDs via System Settings → Wi-Fi (the `airport`
|
||||
# command was removed in macOS Sequoia+); connect (replace with actual SSID):
|
||||
networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux (NetworkManager) — one-shot connect, no password (open AP):
|
||||
nmcli device wifi connect "Bose SoundTouch XXXX"
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows — connect via the built-in Wi-Fi menu, or from PowerShell:
|
||||
netsh wlan connect name="Bose SoundTouch XXXX"
|
||||
```
|
||||
|
||||
The speaker's web UI gateway is at `192.0.2.1` (verified: ST10 assigns `192.0.2.2` to the client via DHCP).
|
||||
|
||||
```bash
|
||||
@@ -143,20 +153,37 @@ Expected response: `<?xml version="1.0" encoding="UTF-8" ?><AddWirelessProfileRe
|
||||
|
||||
The speaker will disconnect from AP mode and join the home network within ~15–30 s.
|
||||
|
||||
### 6.4 Reconnect Mac to Home Network
|
||||
### 6.4 Reconnect to your Home Network
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
networksetup -setairportnetwork en0 "MyHomeNetwork" "MyPassword"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux (NetworkManager) — assumes the connection profile already exists
|
||||
# (e.g. from a prior manual connect); use `nmcli device wifi connect
|
||||
# "MyHomeNetwork" password "MyPassword"` instead for a first-time connect.
|
||||
nmcli connection up "MyHomeNetwork"
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows
|
||||
netsh wlan connect name="MyHomeNetwork"
|
||||
```
|
||||
|
||||
Wait ~15 s for the speaker to join the home network, then verify:
|
||||
|
||||
```bash
|
||||
# Discover the speaker's new IP via mDNS
|
||||
dns-sd -B _soundtouch._tcp local &
|
||||
sleep 5 ; kill %1
|
||||
# macOS/Linux — discover the speaker's new IP via mDNS.
|
||||
# macOS: dns-sd ships with the OS. Linux: use avahi-browse (avahi-utils package).
|
||||
dns-sd -B _soundtouch._tcp local & # macOS
|
||||
avahi-browse -r _soundtouch._tcp # Linux — Ctrl-C to stop
|
||||
sleep 5 ; kill %1 2>/dev/null # only needed for the dns-sd form
|
||||
```
|
||||
|
||||
Windows has no equivalent built-in mDNS browser; use `soundtouch-cli discover devices` (this repo's own mDNS/UPnP discovery, cross-platform) or check your router's DHCP client list instead.
|
||||
|
||||
---
|
||||
|
||||
## Comparison: Initial Setup vs. Migration
|
||||
|
||||
@@ -40,7 +40,7 @@ systemd unit that starts on boot.
|
||||
To pin a specific version instead of the latest:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh v0.111.3
|
||||
sudo bash install.sh v0.123.0
|
||||
```
|
||||
|
||||
Check that the service is running:
|
||||
@@ -278,7 +278,7 @@ curl -s http://192.0.2.1:8090/presets
|
||||
|
||||
```bash
|
||||
sudo bash install.sh # updates to latest release
|
||||
sudo bash install.sh v0.111.3 # updates to a specific version
|
||||
sudo bash install.sh v0.123.0 # updates to a specific version
|
||||
```
|
||||
|
||||
The installer stops the service, downloads the new binary, and restarts
|
||||
|
||||
@@ -107,6 +107,10 @@ Open `http://<server>:8000` and go to the **Settings** tab.
|
||||
|
||||
Set the **Target Domain** to the address your speakers can reach — for example `https://soundtouch.fritz.box` or `http://192.0.2.100:8000`. This must be the host's address on your local network, not `localhost`.
|
||||
|
||||
> **Changing this later?** Saving Settings only updates AfterTouch's own record of its address — it does **not** reach out to any already-migrated speaker. Each speaker only learns a new address when you (re-)run Migrate for it (Step 5 below), regardless of migration method. If you change Target Domain after some speakers are already migrated, re-migrate each of them too, or they'll keep using whatever address they were originally migrated with. See [Troubleshooting: Changing Target Domain doesn't change what a speaker actually uses](TROUBLESHOOTING.md#settings-vs-migrate).
|
||||
|
||||
> **On-device install:** this "not `localhost`" rule is for the local-network-host and cloud/VPS scenarios above, where the service runs on a *different* machine than the speaker. If you're running AfterTouch directly on the speaker itself (see the [On-Device Install Walkthrough](ON-DEVICE-INSTALL-WALKTHROUGH.md)), the speaker and the service are the same machine — `http://localhost:8000` is exactly right there, and is the recommended value: it needs no DNS/mDNS to resolve and survives DHCP address changes since it never depends on the LAN address at all. Installs built after issue #546's fix set this automatically (via `DEPLOYMENT_MODE=on-device`); on older installs, or if the field still shows the speaker's own unresolvable Linux hostname (e.g. `http://spotty:8000`), set it here by hand.
|
||||
|
||||
If you plan to use DNS/DHCP redirect, enable the **DNS Discovery Server** and set the **DNS Bind Address** to `:53`. The upstream DNS should be your router's IP, not the service's own address.
|
||||
|
||||
> **Tip**: If you change settings and they don't seem to take effect, check `data/settings.json` — settings saved in the UI take precedence over environment variables.
|
||||
@@ -127,6 +131,14 @@ The XML migration writes updated configuration to the speaker's filesystem, whic
|
||||
4. Power-cycle the speaker (unplug the power cable, wait 10 seconds, reconnect).
|
||||
5. After boot, root SSH is available with no password: `ssh -oHostKeyAlgorithms=+ssh-rsa root@<SPEAKER-IP>`
|
||||
|
||||
**Or, without a USB stick:** `soundtouch-cli setup enable-ssh` (#471) bootstraps SSH purely over the network, using the speaker's telnet:17000 diagnostic shell (open by default on most firmware) to inject the SSH-enable command:
|
||||
|
||||
```shell
|
||||
soundtouch-cli --host <SPEAKER-IP> setup enable-ssh
|
||||
```
|
||||
|
||||
It waits for `:22` to come up and persists the change (survives a reboot) by default. Falls back to the USB-stick method above if telnet:17000 is closed or the injection doesn't take on your model.
|
||||
|
||||
You only need to do this once per speaker. SSH can remain enabled for future maintenance or be disabled after migration — your choice.
|
||||
|
||||
**To disable SSH after migration:**
|
||||
|
||||
@@ -14,7 +14,9 @@ documenting a successful fresh installation on a SoundTouch 20 Series I.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- SSH enabled on the speaker (the usual "Stick with remote_services" procedure).
|
||||
- SSH enabled on the speaker — either the usual "USB stick with
|
||||
`remote_services`" procedure, or `soundtouch-cli setup enable-ssh`
|
||||
(no stick needed, see Step 1).
|
||||
- Your machine can reach the speaker on the LAN.
|
||||
- The speaker's LAN IP address — replace `192.0.2.1` throughout with the
|
||||
actual address shown in your router or `arp -a`.
|
||||
@@ -29,6 +31,23 @@ documenting a successful fresh installation on a SoundTouch 20 Series I.
|
||||
|
||||
## Step 1 — Connect to the speaker via SSH
|
||||
|
||||
If SSH isn't enabled yet, you don't need a USB stick: `soundtouch-cli` can
|
||||
bootstrap it purely over the network (#471), using the speaker's
|
||||
telnet:17000 diagnostic shell (open by default on most firmware) to inject
|
||||
the SSH-enable command:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host 192.0.2.1 setup enable-ssh
|
||||
```
|
||||
|
||||
This waits for `:22` to come up and persists it (survives a reboot) by
|
||||
default. The USB-stick method (format FAT32, create an empty
|
||||
`remote_services` file in its root, insert, power-cycle) still works as a
|
||||
fallback if telnet:17000 is closed or the injection doesn't take on your
|
||||
model.
|
||||
|
||||
Either way, connect the same way:
|
||||
|
||||
```bash
|
||||
ssh -oHostKeyAlgorithms=+ssh-rsa root@192.0.2.1
|
||||
```
|
||||
@@ -65,7 +84,7 @@ rm -f /mnt/nv/aftertouch/soundtouch-cli
|
||||
df -h /mnt/nv # confirm space recovered
|
||||
```
|
||||
|
||||
> **From v0.89.0 onwards the installer prunes stale artefacts automatically**
|
||||
> **From v0.93.0 onwards the installer prunes stale artefacts automatically**
|
||||
> during every upgrade — manual cleanup should no longer be necessary on
|
||||
> fresh installs.
|
||||
|
||||
@@ -85,11 +104,14 @@ By default this installs the **latest release** — the script resolves it from
|
||||
GitHub's `releases/latest` redirect. To target a specific version instead:
|
||||
|
||||
```bash
|
||||
# Via environment variable (works with pipe-to-sh)
|
||||
VERSION=0.111.3 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
# Via environment variable — note it goes on `sh`, not `curl`: shell
|
||||
# variable-assignment prefixes only apply to the one command they're
|
||||
# attached to, and in a pipe each command is a separate process.
|
||||
# `VERSION=0.123.0 curl ... | sh` silently does NOT set it for `sh`.
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | VERSION=0.123.0 sh
|
||||
|
||||
# Via command-line flag (pass args after sh -s --)
|
||||
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.111.3
|
||||
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.123.0
|
||||
```
|
||||
|
||||
Verify the installed version:
|
||||
@@ -98,7 +120,7 @@ Verify the installed version:
|
||||
wget -qO- http://localhost:8000/health
|
||||
```
|
||||
|
||||
The JSON response should include `"version":"v0.111.3"` (or whichever
|
||||
The JSON response should include `"version":"v0.123.0"` (or whichever
|
||||
version you installed).
|
||||
|
||||
---
|
||||
@@ -131,13 +153,69 @@ ssh -oHostKeyAlgorithms=+ssh-rsa -L 8000:localhost:8000 root@192.0.2.1
|
||||
Keep this terminal open. Navigate to **http://localhost:8000** in your
|
||||
browser.
|
||||
|
||||
> Skip this step if your speaker's firmware exposes port 8000 on the LAN
|
||||
> directly — you can reach `http://192.0.2.1:8000` without a tunnel in that
|
||||
> case.
|
||||
> **You may not need the tunnel at all.** Try `http://192.0.2.1:8000` first.
|
||||
> If that doesn't load, try **`http://192.0.2.1:17008`**: on speakers whose
|
||||
> Wi-Fi co-processor refuses to pass `:8000` through (the ST20 and likely
|
||||
> others), the installer automatically redirects port `17008` to AfterTouch,
|
||||
> so the Admin UI is reachable from the LAN without any tunnel. Check with
|
||||
> `/etc/init.d/aftertouch status` on the speaker, which reports the LAN port
|
||||
> when the redirect is active. Details and per-model status:
|
||||
> [Model Support Matrix](../reference/MODEL-SUPPORT-MATRIX.md).
|
||||
>
|
||||
> Keep the tunnel in mind anyway for **linking music-service accounts**:
|
||||
> Spotify only accepts `https://` or *loopback* OAuth redirect URIs, so
|
||||
> `http://localhost:8000` through a tunnel succeeds where a plain LAN
|
||||
> address is rejected.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Run the Health QuickFix for empty `margeAccountUUID`
|
||||
## Step 6 — Migrate (point the speaker at itself)
|
||||
|
||||
The speaker isn't pointed at the AfterTouch instance you just installed yet
|
||||
— this step does that. On-device, the speaker and the AfterTouch instance
|
||||
are the same machine, so **loopback is the correct and recommended Target
|
||||
Domain value**: `http://localhost:8000`. This is the one case where the
|
||||
general migration guide's "must not be `localhost`" warning does not
|
||||
apply — that warning is about the external-host/cloud scenarios, where
|
||||
`localhost` would resolve on the wrong machine (the service host, not the
|
||||
speaker). Here there is no wrong machine to resolve on.
|
||||
|
||||
> **Note:** as of the fix for issue #546, the on-device init script already
|
||||
> sets `DEPLOYMENT_MODE=on-device`, so a fresh (or reinstalled/updated)
|
||||
> on-device install's own Target Domain already defaults to
|
||||
> `http://localhost:8000` automatically — no manual Settings-tab step
|
||||
> needed for that part. Older installs still default to the speaker's own
|
||||
> unresolvable Linux hostname (e.g. `http://spotty:8000`) until reinstalled
|
||||
> with a build that includes the fix, or until the Target Domain is
|
||||
> corrected by hand. Either way, you still need to run Migrate below — that
|
||||
> step tells the *speaker* to use this address, which is separate from what
|
||||
> the service defaults its own identity to.
|
||||
|
||||
**Via the Admin UI:**
|
||||
|
||||
1. Go to **Settings**, set **Target Domain** to `http://localhost:8000`.
|
||||
2. Go to **Devices**, find your speaker (it self-discovers on its own LAN
|
||||
IP), click **Migrate**.
|
||||
3. Accept the suggested plan and let it apply.
|
||||
4. Reboot to apply the change:
|
||||
```bash
|
||||
sync
|
||||
reboot
|
||||
```
|
||||
|
||||
**Or via the CLI** (equivalent, no browser needed — grab `soundtouch-cli`
|
||||
from Step 9 below first if you want this path):
|
||||
|
||||
```bash
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 setup migrate \
|
||||
--service-url http://localhost:8000 --method telnet
|
||||
sync
|
||||
reboot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Run the Health QuickFix for empty `margeAccountUUID`
|
||||
|
||||
In the AfterTouch UI:
|
||||
|
||||
@@ -148,6 +226,14 @@ In the AfterTouch UI:
|
||||
4. Click the **QuickFix** button (labelled "Fix", "Pair account", or
|
||||
"Apply QuickFix" depending on the version) and confirm.
|
||||
|
||||
Or via the CLI (same underlying pairing call, `--mode=bare` matches what
|
||||
the QuickFix does — see Step 9 to grab `soundtouch-cli` first):
|
||||
|
||||
```bash
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 setup pair \
|
||||
--mode=bare --account=1111111 --service-url http://localhost:8000
|
||||
```
|
||||
|
||||
Then reboot again to let the pairing take effect:
|
||||
|
||||
```bash
|
||||
@@ -157,7 +243,7 @@ reboot
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Verify pairing and sources
|
||||
## Step 8 — Verify pairing and sources
|
||||
|
||||
After the reboot reconnect via SSH and check:
|
||||
|
||||
@@ -171,32 +257,37 @@ wget -qO- http://localhost:8090/info | grep margeAccountUUID
|
||||
wget -qO- http://localhost:8090/sources
|
||||
```
|
||||
|
||||
If `margeAccountUUID` is still empty, re-run the Health QuickFix (Step 6)
|
||||
If `margeAccountUUID` is still empty, re-run the Health QuickFix (Step 7)
|
||||
and reboot again.
|
||||
|
||||
---
|
||||
|
||||
## Step 8 — Download soundtouch-cli (optional, for preset setup)
|
||||
## Step 9 — Download soundtouch-cli (optional, for preset setup)
|
||||
|
||||
If you want to program preset buttons from the command line, download the
|
||||
CLI binary to the speaker's `/tmp` (tmpfs, so it survives only until the
|
||||
next reboot — which is fine for a one-time setup run):
|
||||
CLI binary to `/mnt/nv/aftertouch` (the same persistent partition
|
||||
AfterTouch itself lives on) rather than `/tmp`: `/tmp` is tmpfs and gets
|
||||
wiped on every reboot, and if you used the CLI alternatives in Steps 6/7
|
||||
above, it needs to survive those steps' reboots too, not just the final
|
||||
one:
|
||||
|
||||
```bash
|
||||
cd /tmp
|
||||
cd /mnt/nv/aftertouch
|
||||
|
||||
curl -L --fail -o soundtouch-cli \
|
||||
https://github.com/gesellix/Bose-SoundTouch/releases/download/v0.111.3/soundtouch-cli-v0.111.3-linux-armv7
|
||||
https://github.com/gesellix/Bose-SoundTouch/releases/download/v0.123.0/soundtouch-cli-v0.123.0-linux-armv7
|
||||
chmod +x soundtouch-cli
|
||||
|
||||
/tmp/soundtouch-cli --version
|
||||
/mnt/nv/aftertouch/soundtouch-cli --version
|
||||
```
|
||||
|
||||
Replace `v0.111.3` with the version you installed.
|
||||
Replace `v0.123.0` with the version you installed. If you want the CLI
|
||||
alternatives in Steps 6/7, download it here first, before doing those
|
||||
steps — it'll be in place and already persistent either way.
|
||||
|
||||
---
|
||||
|
||||
## Step 9 — Store custom radio streams to preset buttons
|
||||
## Step 10 — Store custom radio streams to preset buttons
|
||||
|
||||
Each station must be playing before it can be saved. The `sleep 5` gives
|
||||
the speaker time to buffer and confirm the stream before storing.
|
||||
@@ -206,52 +297,52 @@ the speaker time to buffer and confirm the stream before storing.
|
||||
|
||||
```bash
|
||||
# Preset 1 — Hitradio OE3
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "http://orf-live.ors-shoutcast.at/oe3-q2a" \
|
||||
--name "Hitradio OE3" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 1
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 1
|
||||
|
||||
# Preset 2 — Lounge FM
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "http://188.138.9.183/digital.mp3" \
|
||||
--name "Lounge FM" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 2
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 2
|
||||
|
||||
# Preset 3 — Country Nonstop
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "https://stream.laut.fm/country-nonstop" \
|
||||
--name "Country Nonstop" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 3
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 3
|
||||
|
||||
# Preset 4 — Radio Piterpan
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "https://klasse1.fluidstream.eu/piterpan.mp3?FLID=8" \
|
||||
--name "Radio Piterpan" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 4
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 4
|
||||
|
||||
# Preset 5 — kronehit
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "https://secureonair.krone.at/kronehit-hp.mp3" \
|
||||
--name "kronehit" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 5
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 5
|
||||
|
||||
# Preset 6 — Radio Niederösterreich
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
|
||||
--url "http://orf-live.ors-shoutcast.at/noe-q2a" \
|
||||
--name "Radio Niederoesterreich" \
|
||||
--service-url "http://localhost:8000"
|
||||
sleep 5
|
||||
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 6
|
||||
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 6
|
||||
```
|
||||
|
||||
These are the stations from weissigera's setup (Austrian public and
|
||||
@@ -260,7 +351,7 @@ pattern is the same regardless of station.
|
||||
|
||||
---
|
||||
|
||||
## Step 10 — Verify presets and final reboot
|
||||
## Step 11 — Verify presets and final reboot
|
||||
|
||||
```bash
|
||||
wget -qO- http://localhost:8090/presets
|
||||
@@ -286,7 +377,7 @@ should start playing the corresponding stream.
|
||||
| SSH "no matching host key type" | Add `-oHostKeyAlgorithms=+ssh-rsa` |
|
||||
| Port 8000 not reachable from LAN | Use the SSH tunnel (Step 5) |
|
||||
| `margeAccountUUID` still empty after reboot | Re-run Health QuickFix, reboot again |
|
||||
| Radio source error 1005 | `margeAccountUUID` is empty — complete Step 6 first |
|
||||
| Radio source error 1005 | `margeAccountUUID` is empty — complete Step 7 first |
|
||||
| `http://localhost:8000` not responding after install | `logread \| grep aftertouch \| tail -20` |
|
||||
| No space left on device during install | Run the cleanup in Step 2; check `df -h /mnt/nv` |
|
||||
|
||||
@@ -305,14 +396,21 @@ older artefacts to keep `/mnt/nv` free:
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
|
||||
# Update to a specific version — three equivalent forms
|
||||
VERSION=0.111.3 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | VERSION=0.123.0 sh
|
||||
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.111.3
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.123.0
|
||||
|
||||
curl -sSLo install.sh https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh
|
||||
sh install.sh --version 0.111.3
|
||||
sh install.sh --version 0.123.0
|
||||
```
|
||||
|
||||
The script's own final output already confirms the new version came up and
|
||||
is answering on `:8000`. If you separately check the version yourself
|
||||
(`wget -qO- http://localhost:8000/health`, or the Admin UI), **reboot the
|
||||
speaker first**: an Admin UI tab left open from before the update, or a
|
||||
browser cache of the previous page load, can otherwise still show the old
|
||||
version even though the new binary is already running.
|
||||
|
||||
**Rollback:** the installer keeps a `.backup` file alongside the binary:
|
||||
|
||||
```bash
|
||||
@@ -322,6 +420,39 @@ cp /mnt/nv/aftertouch/aftertouch-service.<old-version>.backup \
|
||||
/etc/init.d/aftertouch restart
|
||||
```
|
||||
|
||||
**Testing a pre-release build (from `main`, not yet tagged):** `install.sh`
|
||||
only ever downloads from GitHub Releases, so there's no one-line installer
|
||||
for an unreleased commit. Cross-compile and swap the binary manually
|
||||
instead — this is a direct extension of the rollback procedure above:
|
||||
|
||||
```bash
|
||||
# On your own machine, from a checkout of the branch/commit you want:
|
||||
make build-linux-armv7 # builds build/soundtouch-service-linux-armv7,
|
||||
# build/soundtouch-cli-linux-armv7, and
|
||||
# build/soundtouch-backup-linux-armv7
|
||||
|
||||
scp build/soundtouch-service-linux-armv7 root@192.0.2.1:/mnt/nv/aftertouch/aftertouch-service.new
|
||||
ssh -oHostKeyAlgorithms=+ssh-rsa root@192.0.2.1
|
||||
|
||||
rw
|
||||
/etc/init.d/aftertouch stop
|
||||
cp /mnt/nv/aftertouch/aftertouch-service /mnt/nv/aftertouch/aftertouch-service.pre-test.backup
|
||||
mv /mnt/nv/aftertouch/aftertouch-service.new /mnt/nv/aftertouch/aftertouch-service
|
||||
chmod +x /mnt/nv/aftertouch/aftertouch-service
|
||||
/etc/init.d/aftertouch start
|
||||
```
|
||||
|
||||
If you're testing an unreleased `soundtouch-cli` change (not just the
|
||||
service), swap that binary too — same idea, and it lands in the same
|
||||
`/mnt/nv/aftertouch` directory Step 9 above uses:
|
||||
|
||||
```bash
|
||||
scp build/soundtouch-cli-linux-armv7 root@192.0.2.1:/mnt/nv/aftertouch/soundtouch-cli
|
||||
ssh -oHostKeyAlgorithms=+ssh-rsa root@192.0.2.1 chmod +x /mnt/nv/aftertouch/soundtouch-cli
|
||||
```
|
||||
|
||||
Roll back the same way as above, using the `.pre-test.backup` file.
|
||||
|
||||
---
|
||||
|
||||
## Service management
|
||||
|
||||
@@ -40,14 +40,14 @@ sudo bash install.sh
|
||||
Install a specific version:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh v0.111.3
|
||||
sudo bash install.sh v0.123.0
|
||||
```
|
||||
|
||||
Override defaults at install time:
|
||||
|
||||
```bash
|
||||
sudo \
|
||||
VERSION=v0.111.3 \
|
||||
VERSION=v0.123.0 \
|
||||
HOSTNAME_FQDN=soundtouch.local \
|
||||
HTTP_PORT=80 \
|
||||
HTTPS_PORT=443 \
|
||||
@@ -105,7 +105,7 @@ journalctl -u soundtouch-service -b # this boot only
|
||||
|
||||
```bash
|
||||
sudo bash install.sh # update to latest release
|
||||
sudo bash install.sh v0.111.3 # update to a specific version
|
||||
sudo bash install.sh v0.123.0 # update to a specific version
|
||||
```
|
||||
|
||||
The script stops the service, downloads the new binary (backs up the old one to
|
||||
@@ -157,14 +157,14 @@ sudo bash install-player.sh
|
||||
Install a specific version:
|
||||
|
||||
```bash
|
||||
sudo bash install-player.sh v0.111.3
|
||||
sudo bash install-player.sh v0.123.0
|
||||
```
|
||||
|
||||
Override defaults at install time:
|
||||
|
||||
```bash
|
||||
sudo \
|
||||
VERSION=v0.111.3 \
|
||||
VERSION=v0.123.0 \
|
||||
HTTP_PORT=8081 \
|
||||
bash install-player.sh
|
||||
```
|
||||
@@ -252,7 +252,7 @@ journalctl -u soundtouch-player -f
|
||||
|
||||
```bash
|
||||
sudo bash install-player.sh # update to latest release
|
||||
sudo bash install-player.sh v0.111.3 # update to a specific version
|
||||
sudo bash install-player.sh v0.123.0 # update to a specific version
|
||||
```
|
||||
|
||||
### Removal
|
||||
|
||||
@@ -156,32 +156,33 @@ The service supports multiple ways to configure its behavior. When multiple sour
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Variable | Flag | Description | Default |
|
||||
|------------------------------------|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
|
||||
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
|
||||
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
|
||||
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
|
||||
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
|
||||
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL. An override: when empty it is derived from `SERVER_URL` (same host, `https`, on `HTTPS_PORT`), and can also be viewed/overridden in Settings. | derived from `SERVER_URL` |
|
||||
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
|
||||
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
|
||||
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
|
||||
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
|
||||
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
|
||||
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
|
||||
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
|
||||
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
|
||||
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
|
||||
| `UPDATE_CHECK_ENABLED` | `--update-check-enabled` | Periodically check GitHub Releases for a newer version and show a dismissible notice in the admin UI and Player when one is found. **Opt-in**: this is the only network call AfterTouch makes beyond speaker/provider traffic when enabled, so it defaults off. One unauthenticated `GET` per interval to `api.github.com`, nothing else leaves the box. Also available as an "Update Check" toggle on the admin Settings page, which applies without a restart; the env var/flag is the seed value for a fresh install with no `settings.json` yet. | `false` |
|
||||
| `UPDATE_CHECK_INTERVAL` | `--update-check-interval` | Update check interval. Also editable on the admin Settings page (applies without a restart). | `24h` |
|
||||
| `MGMT_USERNAME` | `--mgmt-username` | Username for HTTP Basic Auth on the Management API (`/api/mgmt/*`, `/mgmt/*`) — Spotify/Amazon account linking, Local Accounts | `admin` |
|
||||
| `MGMT_PASSWORD` | `--mgmt-password` | Password for the same Management API Basic Auth. **Change this if AfterTouch is reachable beyond a trusted LAN** — the default is published in this doc. | `change_me!` |
|
||||
| `STOCKHOLM_DIR` | `--stockholm-dir` | Path to extracted Stockholm frontend directory — enables the Stockholm UI when set | *(disabled)* |
|
||||
| `MARGE_URL` | | Streaming/marge base URL used when rewriting `stockholm/json/config.json`. Defaults to `SERVER_URL`. Set to `SERVER_URL/marge` only when using a soundcork backend. | *(same as `SERVER_URL`)* |
|
||||
| `MARGE_AUTH_TOKEN` | | Pre-seeds the Stockholm `margeAuthToken` state (skips the login step for the first session) | *(empty)* |
|
||||
| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* |
|
||||
| Variable | Flag | Description | Default |
|
||||
|------------------------------------|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------|
|
||||
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
|
||||
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
|
||||
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
|
||||
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
|
||||
| `DEPLOYMENT_MODE` | `--deployment-mode` | Where this service runs: `on-device`, `private-network`, or `public-network`. Only changes behavior when `SERVER_URL` is *not* set: `on-device` defaults to `http://localhost:<port>` instead of guessing a hostname (the speaker's own Linux hostname is never resolvable — see issue #546); `public-network` refuses to start rather than guess a publicly reachable address; unset/`private-network` keeps the previous hostname-guessing behavior, now with a startup warning. The on-device install script sets this automatically. | unset (legacy hostname guess, with warning) |
|
||||
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
|
||||
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL. An override: when empty it is derived from `SERVER_URL` (same host, `https`, on `HTTPS_PORT`), and can also be viewed/overridden in Settings. | derived from `SERVER_URL` |
|
||||
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
|
||||
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
|
||||
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
|
||||
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
|
||||
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
|
||||
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
|
||||
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
|
||||
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
|
||||
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
|
||||
| `UPDATE_CHECK_ENABLED` | `--update-check-enabled` | Periodically check GitHub Releases for a newer version and show a dismissible notice in the admin UI and Player when one is found. **Opt-in**: this is the only network call AfterTouch makes beyond speaker/provider traffic when enabled, so it defaults off. One unauthenticated `GET` per interval to `api.github.com`, nothing else leaves the box. Also available as an "Update Check" toggle on the admin Settings page, which applies without a restart; the env var/flag is the seed value for a fresh install with no `settings.json` yet. | `false` |
|
||||
| `UPDATE_CHECK_INTERVAL` | `--update-check-interval` | Update check interval. Also editable on the admin Settings page (applies without a restart). | `24h` |
|
||||
| `MGMT_USERNAME` | `--mgmt-username` | Username for HTTP Basic Auth on the Management API (`/api/mgmt/*`, `/mgmt/*`) — Spotify/Amazon account linking, Local Accounts | `admin` |
|
||||
| `MGMT_PASSWORD` | `--mgmt-password` | Password for the same Management API Basic Auth. **Change this if AfterTouch is reachable beyond a trusted LAN** — the default is published in this doc. | `change_me!` |
|
||||
| `STOCKHOLM_DIR` | `--stockholm-dir` | Path to extracted Stockholm frontend directory — enables the Stockholm UI when set | *(disabled)* |
|
||||
| `MARGE_URL` | | Streaming/marge base URL used when rewriting `stockholm/json/config.json`. Defaults to `SERVER_URL`. Set to `SERVER_URL/marge` only when using a soundcork backend. | *(same as `SERVER_URL`)* |
|
||||
| `MARGE_AUTH_TOKEN` | | Pre-seeds the Stockholm `margeAuthToken` state (skips the login step for the first session) | *(empty)* |
|
||||
| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* |
|
||||
|
||||
### Configuration Examples
|
||||
|
||||
|
||||
@@ -594,6 +594,31 @@ Once the source plays once, it gets persisted to `/mnt/nv/BoseApp-Persistence/1/
|
||||
|
||||
If `soundtouch-cli source content --source TUNEIN ...` returns `1005` on a reset device that has never had TuneIn, the speaker is refusing because the source isn't registered yet — chicken-and-egg. The SoundTouch app is then the only practical path to register it; we can't write `Sources.xml` directly over telnet on most models.
|
||||
|
||||
### ❌ Changing Target Domain in Settings doesn't change what a speaker actually uses {#settings-vs-migrate}
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- You update **Settings → Target Domain / Server URL** (via the Admin UI, `SERVER_URL`, or `--deployment-mode`), and the Admin UI confirms the new value with no warning.
|
||||
- An already-migrated speaker's own behavior is unchanged: playback/BMX requests still go to the *old* address, and `soundtouch-cli setup inspect --telnet` still shows the old `margeServerUrl`/`statsServerUrl`/`bmxRegistryUrl`/`swUpdateUrl`.
|
||||
|
||||
**Cause:** Settings only updates the *service's own* record of its address (`s.serverURL`, persisted to `settings.json`) — the save handler never contacts any device. A speaker only learns a new address at migrate time: the telnet method writes it via `sys configuration ...` plus a closing `envswitch boseurls set ...` for the reboot-persisted layer; the XML/SSH method uploads a fresh `SoundTouchSdkPrivateCfg.xml`. Both write **once**, with no mechanism for a speaker to later re-fetch its own config from the service — this is equally true for either migration method. A "Sync" or `sourcesUpdated` notification only refreshes the speaker's source *list*, not its server URL configuration.
|
||||
|
||||
**Fix:** Any Target Domain change that needs to reach an already-migrated speaker requires a fresh Migrate afterward — Settings alone is never enough for a speaker that's been migrated before:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <speaker-ip> setup migrate --method telnet --service-url <new-target-domain>
|
||||
```
|
||||
|
||||
Confirm it took:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <speaker-ip> setup inspect --telnet
|
||||
```
|
||||
|
||||
`margeServerUrl`/`statsServerUrl`/`bmxRegistryUrl`/`swUpdateUrl` should all match the new value. Repeat per speaker — Settings is one service-wide value, but each speaker keeps its own independently-migrated copy, so a multi-speaker household needs a re-migrate for each one.
|
||||
|
||||
This also applies to a freshly-fixed on-device default (see `DEPLOYMENT_MODE`, #546): the installer now gets the *default* right for new installs automatically, but an install that was already migrated before you updated still needs the explicit re-migrate above — the fix only stops a *new* bad value from being written, it doesn't retroactively correct an already-migrated speaker.
|
||||
|
||||
### ❌ Radio sources never activate after an in-place migration {#radio-sources-after-migration}
|
||||
|
||||
**Symptoms:**
|
||||
@@ -628,7 +653,8 @@ Notes:
|
||||
|
||||
If the telnet method isn't available for your model, factory reset the speaker, then re-migrate it:
|
||||
|
||||
1. Factory reset (on most models: hold `1` + `−` for ~10 seconds).
|
||||
1. Factory reset (on most models: hold `1` + `−` for ~10 seconds — confirmed
|
||||
identical on the SoundTouch 30 Series III, not just the original ST30).
|
||||
2. Reconnect the speaker to your network.
|
||||
3. Re-migrate it in AfterTouch.
|
||||
|
||||
@@ -648,6 +674,71 @@ Confirmed on hardware across five device variants (2026-08-09): different ports
|
||||
|
||||
**Fix:** After a power-cycle, wait at least 90 seconds before retrying any telnet-based command. If it still fails after that, wait a full 2 minutes before assuming the port is genuinely closed on that firmware rather than just slow to come up.
|
||||
|
||||
### ❌ Speaker gets slower/less responsive over time after `setup enable-ssh` with no `--service-url`
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- You ran `soundtouch-cli setup enable-ssh` without `--service-url` (or via the Admin UI's equivalent) to bootstrap SSH, and never followed up with a real `setup migrate`.
|
||||
- Over time (hours to days), the speaker becomes progressively less responsive — slow to answer `:8090`, SSH connections time out, the Admin UI shows it as flaky or offline.
|
||||
|
||||
**Cause:**
|
||||
|
||||
`enable-ssh` without `--service-url` writes a deliberately-invalid placeholder (`https://aftertouch.invalid`) into `margeServerUrl`/`swUpdateUrl`/etc — by design, since the SSH-enable injection only needs *a* URL to round-trip through, not a working one. But unless you run `setup migrate` (or the Admin UI's Migrate step) afterward, that placeholder **stays persisted** — the command's own success message says so explicitly. The firmware then retries a failing DNS/curl lookup against it on a background loop (same class of failure as the `mojo`/`taigan` unresolvable-hostname case, #546) — an ongoing resource drain that isn't dramatic on its own, but confirmed on real hardware (2026-08-16) to compound badly if anything else (e.g. a burst of SSH connections — see the `setup revert` entry below) puts the speaker under load at the same time.
|
||||
|
||||
**Fix:** Always follow `enable-ssh` (when run without `--service-url`) with a real `setup migrate` before walking away. If you're recovering a speaker that's already stuck like this: power-cycle it, confirm it's reachable (`ping`, `curl :8090/info`, a single plain `ssh ... echo ok`) before doing anything else, then run `setup migrate` with the real URLs. If you want to point it back at the **original Bose cloud** URLs instead of AfterTouch (e.g. to fully decommission it), use the per-field overrides on `--method=telnet` — see the `setup migrate` section of [CLI-REFERENCE.md](CLI-REFERENCE.md) — which writes over a single telnet connection, no SSH required:
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <SPEAKER-IP> setup migrate --method telnet \
|
||||
--service-url https://streaming.bose.com \
|
||||
--marge-url https://streaming.bose.com \
|
||||
--stats-url https://events.api.bosecm.com \
|
||||
--sw-update-url https://worldwide.bose.com/updates/soundtouch \
|
||||
--bmx-url https://content.api.bose.io/bmx/registry/v1/services
|
||||
```
|
||||
|
||||
### ❌ `setup revert` (or the Admin UI's "Revert to Defaults") fails with "backup .original not found" even though the file exists
|
||||
|
||||
**Status: fixed** (branch `docs-ondevice-install-gaps`, not yet in a numbered release as of this writing) — kept below for anyone hitting this on an older build, and because the underlying "don't hammer a struggling speaker" advice is still good practice generally.
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- You confirm via a separate SSH session that `/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original` genuinely exists.
|
||||
- `setup revert` (or clicking "Revert to Defaults") still reports `backup .../SoundTouchSdkPrivateCfg.xml.original not found, cannot revert`.
|
||||
- A follow-up plain SSH command to the same speaker fails with `Operation timed out` at the TCP level — not an auth or shell error.
|
||||
|
||||
**Cause:** `RevertMigration`'s full call graph opened **17 separate SSH connections** in rapid succession (`pkg/ssh.Client.Run()` dialed fresh every call, with no connection reuse across `revertXMLConfig`/`revertHosts`/`revertResolvConf`/`revertAftertouchHook`/`removeRcLocalHooks`/`revertCACert`). Hitting a resource-constrained embedded speaker with that many rapid reconnects could overwhelm it — confirmed on real hardware (2026-08-16), where the speaker became unreachable shortly after. On top of that, `revertXMLConfig`'s error handling collapses *any* non-nil error from its file-existence check into "not found," so a dial failure got misreported as a missing backup — the message didn't mean what it said.
|
||||
|
||||
**Fix:** `pkg/ssh.Client` now supports an opt-in persistent connection (`Connect()`/`Close()`) that `RevertMigration` uses to collapse those 17 connections into 1 — confirmed on the same real hardware (2026-08-16): a subsequent `setup revert` completed quickly, and the restored config file diffed byte-identical against `.original`. If you're on a build that predates this fix, don't retry `setup revert` back-to-back — if it fails, wait a minute and confirm the speaker is reachable again (`ping`, a single plain `ssh ... echo ok`) before retrying. If all you actually need is to point the speaker's URLs somewhere else (back to AfterTouch, or back to the original Bose cloud), the lighter-weight `setup migrate --method telnet` with explicit URL overrides (previous entry) uses one telnet connection instead of SSH entirely.
|
||||
|
||||
### ❌ On-device install: AfterTouch answers on the speaker but not from other machines on the LAN
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- On the speaker itself, `curl http://localhost:8000/health` works and `/etc/init.d/aftertouch status` is green.
|
||||
- From any other machine, `http://<speaker-ip>:8000` fails immediately (connection refused/reset, not a timeout).
|
||||
- SSH to the same speaker works fine, so it is clearly reachable in general.
|
||||
|
||||
**Cause:**
|
||||
|
||||
Some SoundTouch chassis carry a BCO ("SMSC") Wi-Fi/Bluetooth co-processor, and inbound LAN traffic reaches the main Linux SoC only for a fixed set of Bose's *own* service ports, a list that appears to be compiled into the co-processor's firmware. AfterTouch's `:8000` was never part of that original design, so the connection never arrives at the SoC at all. Confirmed on an ST20 (`spotty`, FW 27.0.6) in 2026-08: `tcpdump -i eth0` on the speaker saw **zero packets** for `:8000` while Bose's `:8090`/`:8091`/`:17000` answered normally from the same client. This is not a firewall (the speaker's `iptables` is empty) and not a binding problem (the service does listen on `0.0.0.0:8000`).
|
||||
|
||||
**Fix:**
|
||||
|
||||
The on-device installer handles this automatically: on an affected speaker it redirects a relayed Bose port to AfterTouch, so use:
|
||||
|
||||
```
|
||||
http://<speaker-ip>:17008
|
||||
```
|
||||
|
||||
To check or change it, on the speaker:
|
||||
|
||||
```bash
|
||||
/etc/init.d/aftertouch status # reports the LAN port when active
|
||||
iptables -t nat -S PREROUTING # shows the redirect rule
|
||||
```
|
||||
|
||||
Set `AFTERTOUCH_LAN_PORT` in `/opt/aftertouch/aftertouch.conf` to a different port, or to `none` to disable the redirect and use an SSH tunnel instead; then `/etc/init.d/aftertouch restart`. Note that **linking music-service accounts still works best through the tunnel** (`http://localhost:8000`), because Spotify only accepts `https://` or loopback OAuth redirect URIs. If you also run the `streborn` project on the same speaker, note it defaults to the same port, so change one of them. Which models are affected is tracked in [MODEL-SUPPORT-MATRIX.md](../reference/MODEL-SUPPORT-MATRIX.md).
|
||||
|
||||
## 🔊 **Volume & Audio Issues**
|
||||
|
||||
### ❌ "Volume control not working"
|
||||
|
||||
@@ -100,6 +100,20 @@ All subsequent messages (except `selectLastWiFiSource`, see below) use this enve
|
||||
|
||||
## Phase 2 — Pairing a New Speaker
|
||||
|
||||
> **Preflight (AfterTouch's `setup pair --mode=full`).** Before opening the
|
||||
> WebSocket, AfterTouch reads `GET /supportedURLs` (must list
|
||||
> `/setMargeAccount`) and `GET /soundTouchConfigurationStatus`, and only
|
||||
> runs the state machine below when the status is exactly
|
||||
> `SOUNDTOUCH_NOT_CONFIGURED`. This matters because a speaker can be
|
||||
> reachable, named, and already have a `margeAccountUUID` set, yet still
|
||||
> report `SOUNDTOUCH_NOT_CONFIGURED` — the firmware keeps prompting to
|
||||
> install the Bose app until a full acknowledged pass through this state
|
||||
> machine runs, not just `setMargeAccount` on its own. Already-configured
|
||||
> devices are a no-op; an unsupported route or an unrecognised status value
|
||||
> aborts without writing anything. See
|
||||
> [#615](https://github.com/gesellix/Bose-SoundTouch/issues/615) and
|
||||
> `Manager.PreflightInitPlan` (`pkg/service/setup/marge_pairing.go`).
|
||||
|
||||
### 2.1 Setup State Machine
|
||||
|
||||
The pairing flow uses a setup state machine on the device. States must be sent in order.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
title: "Model Support Matrix"
|
||||
---
|
||||
A living record of how individual SoundTouch models behave with AfterTouch,
|
||||
built up from things actually observed on hardware.
|
||||
|
||||
**This table only claims what someone has verified.** Anything not tested is
|
||||
marked `?` rather than inferred from a similar-looking model. Bose used
|
||||
several different chassis designs across the SoundTouch line, and at least
|
||||
one behaviour (LAN reachability, below) differs between them in a way that is
|
||||
invisible from the outside. If you have a model that isn't filled in yet,
|
||||
[the commands below](#how-to-fill-in-a-row) produce everything a row needs.
|
||||
|
||||
## What the columns mean
|
||||
|
||||
- **variant / moduleType**: the speaker's own identifiers, straight out of
|
||||
`/info`. `variant` is Bose's internal codename for the product; `moduleType`
|
||||
distinguishes chassis generations (`scm` and `sm2` are the two seen so far).
|
||||
- **BCO**: whether the board carries a BCO co-processor (Bose's internal name
|
||||
for the SMSC Wi-Fi/Bluetooth combo chip that also handles AirPlay). Bose's
|
||||
own `has-bco` helper on the device is simply
|
||||
`[ "$(cat /proc/module_type)" = scm ]`.
|
||||
- **`:8000` from LAN**: whether AfterTouch's own port is reachable from
|
||||
another machine on the network *without* any workaround.
|
||||
- **Entry port**: when `:8000` isn't reachable, the port AfterTouch redirects
|
||||
to itself so the admin UI still works. See
|
||||
[LAN access on co-processor chassis](#lan-access-on-co-processor-chassis).
|
||||
|
||||
## Matrix
|
||||
|
||||
| Model | variant | moduleType | BCO | On-device install | `:8000` from LAN | Entry port | Evidence |
|
||||
|---------------------|----------|------------|-----|-------------------|------------------|------------|-----------------------------------------------------------------------|
|
||||
| SoundTouch 20 | `spotty` | `scm` | yes | works | ✗ blocked | `17008` | verified on hardware 2026-08-16 (FW 27.0.6), redirect survives reboot |
|
||||
| SoundTouch 10 | ? | ? | ? | reported working | ? | ? | not tested for LAN reachability |
|
||||
| SoundTouch 30 | ? | ? | ? | reported working | ? | ? | not tested for LAN reachability |
|
||||
| SoundTouch Portable | ? | ? | ? | ? | ? | ? | not tested |
|
||||
| Wave / SA-4 | ? | ? | ? | ? | ? | ? | not tested |
|
||||
|
||||
Not every SoundTouch shares one firmware image, so treat a `?` as genuinely
|
||||
unknown. In particular, do not assume a model is unaffected just because it is
|
||||
newer or older than a model that is.
|
||||
|
||||
## LAN access on co-processor chassis
|
||||
|
||||
On chassis with a BCO co-processor, inbound LAN traffic reaches the speaker's
|
||||
main Linux SoC only for a fixed set of Bose's *own* service ports. That list
|
||||
appears to be compiled into the co-processor's firmware, and AfterTouch's
|
||||
`:8000` is not on it, so a connection attempt never arrives at the SoC at
|
||||
all. On a verified ST20, `tcpdump -i eth0` on the speaker recorded **zero
|
||||
packets** for `:8000` while Bose's `:8090`, `:8091`, `:8200`, `:82`, `:8080`
|
||||
and `:17000` all answered normally from the same client.
|
||||
|
||||
This is not a firewall, and not something AfterTouch can fix by binding
|
||||
differently: the service already listens on `0.0.0.0:8000`, and the speaker's
|
||||
`iptables` is empty (there is no `nft` or `ebtables` at all).
|
||||
|
||||
The on-device installer works around it by redirecting one of the relayed
|
||||
ports to AfterTouch. **Credit for this technique goes to the
|
||||
[STR / SoundTouch Reborn](https://github.com/JRpersonal/streborn) project**,
|
||||
which documented and shipped it first (their agent uses the same entry port
|
||||
for the same reason); finding their prior art is what turned this from an
|
||||
apparent hardware dead end into a one-line fix:
|
||||
|
||||
```
|
||||
iptables -t nat -I PREROUTING 1 ! -i lo -p tcp --dport 17008 -j REDIRECT --to-ports 8000
|
||||
```
|
||||
|
||||
`17008` is Bose's `SoftwareUpdate` listener. Its cloud service no longer
|
||||
exists, so taking over its inbound traffic costs nothing in practice. Only
|
||||
external traffic is matched (`! -i lo`), so anything running on the speaker
|
||||
still reaches AfterTouch on `:8000` exactly as before.
|
||||
|
||||
The rule is re-applied by the init script on every start, so it survives
|
||||
reboots (confirmed on the ST20) without any background watchdog. It is
|
||||
removed again on `stop` and on uninstall.
|
||||
|
||||
The redirect is applied automatically on chassis that need it, and configured
|
||||
via `AFTERTOUCH_LAN_PORT` in `/opt/aftertouch/aftertouch.conf`:
|
||||
|
||||
| Value | Effect |
|
||||
|------------|-----------------------------------------------------------------|
|
||||
| `auto` | *(default)* redirect only where the co-processor blocks `:8000` |
|
||||
| `none` | never redirect; use an SSH tunnel instead |
|
||||
| *(a port)* | always redirect that inbound port to AfterTouch |
|
||||
|
||||
Two caveats worth knowing:
|
||||
|
||||
- **Account linking still prefers the SSH tunnel.** Spotify only accepts
|
||||
`https://` or *loopback* OAuth redirect URIs, so `http://localhost:8000`
|
||||
through a tunnel works for linking where a plain LAN address does not.
|
||||
- **The `streborn` project defaults to the same port** for the same reason. If
|
||||
you run both on one speaker, change `AFTERTOUCH_LAN_PORT`.
|
||||
|
||||
## How to fill in a row
|
||||
|
||||
Run these from a machine on the same network (replace the address), then open
|
||||
an issue or PR with the output:
|
||||
|
||||
```bash
|
||||
# variant, moduleType, and whether an SCM/SMSC component is listed
|
||||
curl -s http://<speaker-ip>:8090/info
|
||||
|
||||
# is AfterTouch's own port reachable directly? (only meaningful once
|
||||
# AfterTouch is installed on the device)
|
||||
curl -v --max-time 5 http://<speaker-ip>:8000/health
|
||||
|
||||
# which Bose ports the chassis relays at all
|
||||
for p in 82 8080 8090 8091 8200 17000 17008; do
|
||||
printf '%s: ' "$p"
|
||||
curl -s -o /dev/null -w '%{http_code}\n' --max-time 3 "http://<speaker-ip>:$p/" || echo unreachable
|
||||
done
|
||||
```
|
||||
|
||||
And on the speaker itself, if you have SSH access:
|
||||
|
||||
```bash
|
||||
has-bco; echo "has-bco exit status: $?" # 0 = BCO co-processor present
|
||||
cat /proc/module_type /proc/variant
|
||||
```
|
||||
@@ -16,8 +16,8 @@ require (
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/mod v0.39.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/mod v0.40.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/term v0.45.0
|
||||
)
|
||||
|
||||
@@ -36,5 +36,5 @@ require (
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
golang.org/x/tools v0.49.0 // indirect
|
||||
)
|
||||
|
||||
@@ -69,12 +69,12 @@ golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
|
||||
golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY=
|
||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
@@ -89,8 +89,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -2651,6 +2651,19 @@ type Settings struct {
|
||||
// individual format tokens.
|
||||
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
|
||||
|
||||
// AutoResumeOnSourceDisconnect, when true, re-issues a device's last
|
||||
// playing content item if now_playing drops into an error source right
|
||||
// after a healthy one, instead of leaving the speaker silent until a
|
||||
// user manually re-selects it. See #622: some TuneIn streams disconnect
|
||||
// the speaker's own audio pipeline (errorUpdate 1041
|
||||
// SOURCE_DISCONNECTED) on their own, mid-playback, with the SoundTouch
|
||||
// WebSocket control channel staying healthy throughout; the observed
|
||||
// fix is exactly what pressing the preset again does. Opt-in (default
|
||||
// false): this automatically re-triggers content selection without a
|
||||
// user action, which not every operator wants. Hand-edit settings.json
|
||||
// to enable — no admin UI control yet, matching TuneInStreamFormats.
|
||||
AutoResumeOnSourceDisconnect bool `json:"auto_resume_on_source_disconnect,omitempty"`
|
||||
|
||||
// DefaultLanding selects what the root path "/" serves to a browser:
|
||||
// "chooser" (or empty) — the neutral landing page that links to the
|
||||
// player and the admin/setup console;
|
||||
|
||||
@@ -673,28 +673,29 @@ func (s *Server) addSystemFiles(tw *tar.Writer) {
|
||||
// diagSettings is a copy of datastore.Settings with secrets zeroed out so the
|
||||
// struct can be marshalled into the archive without exposing credentials.
|
||||
type diagSettings struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
HTTPSServerURL string `json:"https_server_url,omitempty"`
|
||||
HTTPSServerURLOverride string `json:"https_server_url_override,omitempty"`
|
||||
RedactLogs bool `json:"redact_logs"`
|
||||
LogBodies bool `json:"log_bodies"`
|
||||
RecordInteractions bool `json:"record_interactions"`
|
||||
DiscoveryInterval string `json:"discovery_interval,omitempty"`
|
||||
DiscoveryEnabled bool `json:"discovery_enabled"`
|
||||
DNSEnabled bool `json:"dns_enabled"`
|
||||
DNSUpstream []string `json:"dns_upstream,omitempty"`
|
||||
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
|
||||
InternalPaths []string `json:"internal_paths,omitempty"`
|
||||
Shortcuts map[string]int `json:"shortcuts,omitempty"`
|
||||
SpotifyClientID string `json:"spotify_client_id,omitempty"`
|
||||
SpotifyClientSecret string `json:"spotify_client_secret,omitempty"`
|
||||
SpotifyRedirectURI string `json:"spotify_redirect_uri,omitempty"`
|
||||
AmazonClientID string `json:"amazon_client_id,omitempty"`
|
||||
AmazonClientSecret string `json:"amazon_client_secret,omitempty"`
|
||||
AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"`
|
||||
TrustForwardedHeaders bool `json:"trust_forwarded_headers,omitempty"`
|
||||
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
|
||||
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
|
||||
ServerURL string `json:"server_url"`
|
||||
HTTPSServerURL string `json:"https_server_url,omitempty"`
|
||||
HTTPSServerURLOverride string `json:"https_server_url_override,omitempty"`
|
||||
RedactLogs bool `json:"redact_logs"`
|
||||
LogBodies bool `json:"log_bodies"`
|
||||
RecordInteractions bool `json:"record_interactions"`
|
||||
DiscoveryInterval string `json:"discovery_interval,omitempty"`
|
||||
DiscoveryEnabled bool `json:"discovery_enabled"`
|
||||
DNSEnabled bool `json:"dns_enabled"`
|
||||
DNSUpstream []string `json:"dns_upstream,omitempty"`
|
||||
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
|
||||
InternalPaths []string `json:"internal_paths,omitempty"`
|
||||
Shortcuts map[string]int `json:"shortcuts,omitempty"`
|
||||
SpotifyClientID string `json:"spotify_client_id,omitempty"`
|
||||
SpotifyClientSecret string `json:"spotify_client_secret,omitempty"`
|
||||
SpotifyRedirectURI string `json:"spotify_redirect_uri,omitempty"`
|
||||
AmazonClientID string `json:"amazon_client_id,omitempty"`
|
||||
AmazonClientSecret string `json:"amazon_client_secret,omitempty"`
|
||||
AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"`
|
||||
TrustForwardedHeaders bool `json:"trust_forwarded_headers,omitempty"`
|
||||
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
|
||||
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
|
||||
AutoResumeOnSourceDisconnect bool `json:"auto_resume_on_source_disconnect,omitempty"`
|
||||
}
|
||||
|
||||
// addSettingsJSON serialises the service settings into the archive as
|
||||
@@ -773,28 +774,29 @@ func (s *Server) addSettingsJSON(tw *tar.Writer) {
|
||||
_, effectiveHTTPSURL := s.GetSettings()
|
||||
|
||||
ds := diagSettings{
|
||||
ServerURL: st.ServerURL,
|
||||
HTTPSServerURL: effectiveHTTPSURL,
|
||||
HTTPSServerURLOverride: st.HTTPServerURL,
|
||||
RedactLogs: st.RedactLogs,
|
||||
LogBodies: st.LogBodies,
|
||||
RecordInteractions: st.RecordInteractions,
|
||||
DiscoveryInterval: st.DiscoveryInterval,
|
||||
DiscoveryEnabled: st.DiscoveryEnabled,
|
||||
DNSEnabled: st.DNSEnabled,
|
||||
DNSUpstream: st.DNSUpstream,
|
||||
DNSBindAddr: st.DNSBindAddr,
|
||||
InternalPaths: st.InternalPaths,
|
||||
Shortcuts: st.Shortcuts,
|
||||
SpotifyClientID: st.SpotifyClientID,
|
||||
SpotifyClientSecret: redact(st.SpotifyClientSecret),
|
||||
SpotifyRedirectURI: st.SpotifyRedirectURI,
|
||||
AmazonClientID: st.AmazonClientID,
|
||||
AmazonClientSecret: redact(st.AmazonClientSecret),
|
||||
AmazonRedirectURI: st.AmazonRedirectURI,
|
||||
TrustForwardedHeaders: st.TrustForwardedHeaders,
|
||||
TrustedProxyCIDRs: st.TrustedProxyCIDRs,
|
||||
TuneInStreamFormats: st.TuneInStreamFormats,
|
||||
ServerURL: st.ServerURL,
|
||||
HTTPSServerURL: effectiveHTTPSURL,
|
||||
HTTPSServerURLOverride: st.HTTPServerURL,
|
||||
RedactLogs: st.RedactLogs,
|
||||
LogBodies: st.LogBodies,
|
||||
RecordInteractions: st.RecordInteractions,
|
||||
DiscoveryInterval: st.DiscoveryInterval,
|
||||
DiscoveryEnabled: st.DiscoveryEnabled,
|
||||
DNSEnabled: st.DNSEnabled,
|
||||
DNSUpstream: st.DNSUpstream,
|
||||
DNSBindAddr: st.DNSBindAddr,
|
||||
InternalPaths: st.InternalPaths,
|
||||
Shortcuts: st.Shortcuts,
|
||||
SpotifyClientID: st.SpotifyClientID,
|
||||
SpotifyClientSecret: redact(st.SpotifyClientSecret),
|
||||
SpotifyRedirectURI: st.SpotifyRedirectURI,
|
||||
AmazonClientID: st.AmazonClientID,
|
||||
AmazonClientSecret: redact(st.AmazonClientSecret),
|
||||
AmazonRedirectURI: st.AmazonRedirectURI,
|
||||
TrustForwardedHeaders: st.TrustForwardedHeaders,
|
||||
TrustedProxyCIDRs: st.TrustedProxyCIDRs,
|
||||
TuneInStreamFormats: st.TuneInStreamFormats,
|
||||
AutoResumeOnSourceDisconnect: st.AutoResumeOnSourceDisconnect,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(ds, "", " ")
|
||||
|
||||
@@ -965,3 +965,9 @@ func (m *mockSSH) UploadContent(content []byte, remotePath string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Connect/Close are no-ops here — the mock has no real connection to
|
||||
// reuse, and every test call already goes through Run/UploadContent above
|
||||
// regardless of whether Connect was called first.
|
||||
func (m *mockSSH) Connect() error { return nil }
|
||||
func (m *mockSSH) Close() error { return nil }
|
||||
|
||||
@@ -129,6 +129,20 @@ pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px;
|
||||
background-color: #d32f2f;
|
||||
}
|
||||
|
||||
/* .btn-primary marks the one "do the thing" confirm action of a panel
|
||||
(Save Settings, Apply Suggested/Custom Plan, Enable SSH, …). Everything
|
||||
else stays the plain default button so color consistently signals the
|
||||
same two meanings everywhere: primary = confirm, danger = destructive. */
|
||||
.btn-primary {
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: #1769aa;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -533,7 +533,7 @@
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px">
|
||||
<button onclick="updateSettings()">Save Settings</button>
|
||||
<button class="btn-primary" onclick="updateSettings()">Save Settings</button>
|
||||
<span
|
||||
id="settings-status"
|
||||
style="margin-left: 10px; font-size: 0.9em"
|
||||
@@ -691,9 +691,27 @@
|
||||
class="summary-box"
|
||||
style="display: none"
|
||||
>
|
||||
<h3>
|
||||
Migration Summary for
|
||||
<span id="summary-device-display"></span>
|
||||
<h3 style="display: flex; align-items: baseline; justify-content: space-between">
|
||||
<span>
|
||||
Migration Summary for
|
||||
<span id="summary-device-display"></span>
|
||||
</span>
|
||||
<span style="display: flex; gap: 6px">
|
||||
<button
|
||||
type="button"
|
||||
onclick="refreshSummary()"
|
||||
title="Reload summary for this device"
|
||||
aria-label="Reload summary"
|
||||
style="padding: 2px 8px; font-size: 0.85em; line-height: 1; cursor: pointer; font-weight: normal"
|
||||
>↻ Reload</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick="document.getElementById('migration-summary').style.display = 'none'"
|
||||
title="Hide this summary — doesn't change anything on the speaker"
|
||||
aria-label="Hide summary"
|
||||
style="padding: 2px 8px; font-size: 0.85em; line-height: 1; cursor: pointer; font-weight: normal"
|
||||
>✕ Hide</button>
|
||||
</span>
|
||||
</h3>
|
||||
<input type="hidden" id="summary-device-id"/>
|
||||
<p>Migration Status: <span id="migration-status"></span></p>
|
||||
@@ -739,7 +757,8 @@
|
||||
<button
|
||||
id="trust-ca-btn"
|
||||
type="button"
|
||||
style="display: none; background-color: #607d8b; color: white; border: none; padding: 2px 8px; font-size: 0.85em"
|
||||
class="btn-primary"
|
||||
style="display: none; padding: 2px 8px; font-size: 0.85em"
|
||||
>Trust CA Now</button>
|
||||
<a
|
||||
href="/setup/ca.crt"
|
||||
@@ -760,7 +779,24 @@
|
||||
<tbody>
|
||||
<tr style="border-top: 1px solid #eee">
|
||||
<td style="padding: 4px 8px; width: 170px; color: #555" title="The remote_services file controls whether SSH is available after reboot">SSH (remote_services)</td>
|
||||
<td id="state-remote-services-cell" style="padding: 4px 8px"></td>
|
||||
<td id="state-remote-services-cell" style="padding: 4px 8px">
|
||||
<span id="state-remote-services-line"></span>
|
||||
<span style="margin-left: 12px; white-space: nowrap">
|
||||
<button
|
||||
id="ensure-remote-btn"
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
style="padding: 2px 8px; font-size: 0.85em"
|
||||
>Enable SSH (Persist remote_services)</button>
|
||||
<button
|
||||
id="remove-remote-btn"
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
title="Removes the remote_services file — SSH will be disabled after the next reboot"
|
||||
style="margin-left: 6px; padding: 2px 8px; font-size: 0.85em"
|
||||
>Disable SSH (Remove remote_services)</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="border-top: 1px solid #eee">
|
||||
<td style="padding: 4px 8px; color: #555">Account paired</td>
|
||||
@@ -775,6 +811,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Speaker controls: real device actions that don't depend on
|
||||
the Customize form below, kept always visible rather than
|
||||
behind its collapse (see #621 — Reboot was previously
|
||||
reachable only after expanding "Customize this migration"
|
||||
and scrolling past it). -->
|
||||
<div style="margin: 0 0 16px 0">
|
||||
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Speaker controls</h4>
|
||||
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
|
||||
<button
|
||||
id="revert-migrate-btn"
|
||||
class="btn-danger"
|
||||
style="padding: 10px 20px; display: none"
|
||||
>
|
||||
Revert to Defaults
|
||||
</button>
|
||||
<button
|
||||
id="reboot-speaker-btn"
|
||||
style="padding: 10px 20px"
|
||||
>
|
||||
Reboot Speaker
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pre-flight panel: appears when the user clicks Apply,
|
||||
runs the configured checks live, then auto-proceeds on
|
||||
success or surfaces failures with override buttons. -->
|
||||
@@ -808,7 +868,9 @@
|
||||
background-color: #eefbff;
|
||||
"
|
||||
>
|
||||
<strong>HTTPS Connection Test:</strong><br/>
|
||||
<strong>HTTPS Connection Test:</strong>
|
||||
<span id="connection-test-relevance-note" style="font-size: 0.85em"></span>
|
||||
<br/>
|
||||
<span style="font-size: 0.85em; color: #555"
|
||||
>Verify the device can reach the server over
|
||||
HTTPS.</span
|
||||
@@ -819,25 +881,13 @@
|
||||
<div style="margin-top: 10px">
|
||||
<button
|
||||
id="test-connection-explicit-btn"
|
||||
style="
|
||||
background-color: #607d8b;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
font-size: 0.9em;
|
||||
"
|
||||
style="font-size: 0.9em"
|
||||
>
|
||||
Test with Explicit CA.crt
|
||||
</button>
|
||||
<button
|
||||
id="test-connection-trusted-btn"
|
||||
style="
|
||||
background-color: #607d8b;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
font-size: 0.9em;
|
||||
"
|
||||
style="font-size: 0.9em"
|
||||
>
|
||||
Test with Shared Trust Store
|
||||
</button>
|
||||
@@ -879,13 +929,7 @@
|
||||
<div style="margin-top: 10px">
|
||||
<button
|
||||
id="test-dns-btn"
|
||||
style="
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
font-size: 0.9em;
|
||||
"
|
||||
style="font-size: 0.9em"
|
||||
>
|
||||
Test DNS Redirection
|
||||
</button>
|
||||
@@ -963,7 +1007,7 @@
|
||||
<input
|
||||
type="text"
|
||||
id="plan-marge-url"
|
||||
oninput="validatePlanURLs()"
|
||||
oninput="onPlanURLFieldEdited(this)"
|
||||
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
|
||||
/>
|
||||
</td>
|
||||
@@ -975,7 +1019,7 @@
|
||||
<input
|
||||
type="text"
|
||||
id="plan-stats-url"
|
||||
oninput="validatePlanURLs()"
|
||||
oninput="onPlanURLFieldEdited(this)"
|
||||
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
|
||||
/>
|
||||
</td>
|
||||
@@ -987,7 +1031,7 @@
|
||||
<input
|
||||
type="text"
|
||||
id="plan-sw_update-url"
|
||||
oninput="validatePlanURLs()"
|
||||
oninput="onPlanURLFieldEdited(this)"
|
||||
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
|
||||
/>
|
||||
</td>
|
||||
@@ -999,7 +1043,7 @@
|
||||
<input
|
||||
type="text"
|
||||
id="plan-bmx-url"
|
||||
oninput="validatePlanURLs()"
|
||||
oninput="onPlanURLFieldEdited(this)"
|
||||
style="width: 100%; font-family: monospace; font-size: 0.85em; box-sizing: border-box"
|
||||
/>
|
||||
</td>
|
||||
@@ -1067,6 +1111,7 @@
|
||||
<button
|
||||
type="button"
|
||||
id="plan-apply-btn"
|
||||
class="btn-primary"
|
||||
onclick="applySuggestedPlan()"
|
||||
style="font-size: 0.95em"
|
||||
>Apply Suggested Plan</button>
|
||||
@@ -1150,8 +1195,9 @@
|
||||
<button
|
||||
type="button"
|
||||
id="customize-apply-btn"
|
||||
class="btn-primary"
|
||||
onclick="applyCustomPlan()"
|
||||
style="background-color: #4caf50; color: white; border: none; padding: 8px 14px; font-size: 0.95em"
|
||||
style="padding: 8px 14px; font-size: 0.95em"
|
||||
>Apply Custom Plan</button>
|
||||
<span id="customize-apply-status" style="margin-left: 10px; font-size: 0.9em"></span>
|
||||
</div>
|
||||
@@ -1236,64 +1282,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 15px">
|
||||
<button
|
||||
id="revert-migrate-btn"
|
||||
style="
|
||||
background-color: #ff9800;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
display: none;
|
||||
"
|
||||
>
|
||||
Revert to Defaults
|
||||
</button>
|
||||
<button
|
||||
id="reboot-speaker-btn"
|
||||
style="
|
||||
background-color: #607d8b;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
"
|
||||
>
|
||||
Reboot Speaker
|
||||
</button>
|
||||
<button
|
||||
id="ensure-remote-btn"
|
||||
style="
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
"
|
||||
>
|
||||
Enable SSH (Persist remote_services)
|
||||
</button>
|
||||
<button
|
||||
id="remove-remote-btn"
|
||||
title="Removes the remote_services file — SSH will be disabled after the next reboot"
|
||||
style="
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
"
|
||||
>
|
||||
Disable SSH (Remove remote_services)
|
||||
</button>
|
||||
<button
|
||||
onclick="
|
||||
document.getElementById(
|
||||
'migration-summary',
|
||||
).style.display = 'none'
|
||||
"
|
||||
style="padding: 10px 20px"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2096,7 +2096,7 @@ async function showSummary(deviceId) {
|
||||
if (accountIdEl && summary.account_id) accountIdEl.innerText = summary.account_id;
|
||||
}
|
||||
|
||||
renderMigrationState(summary);
|
||||
renderMigrationState(summary, targetUrl);
|
||||
renderPlan(summary);
|
||||
renderPlanCurrentURLs(summary);
|
||||
renderPlanPairing(summary, deviceId);
|
||||
@@ -2150,6 +2150,20 @@ async function showSummary(deviceId) {
|
||||
connectionTestPane.style.display = summary.ssh_success ? "block" : "none";
|
||||
}
|
||||
|
||||
// Stays visible either way (the user may still want to check it),
|
||||
// but the default Suggested Plan never needs HTTPS — only note it
|
||||
// as required when the Target URL itself is https://.
|
||||
const connectionTestNote = document.getElementById("connection-test-relevance-note");
|
||||
if (connectionTestNote) {
|
||||
if (isHttpsTarget(targetUrl)) {
|
||||
connectionTestNote.innerText = "Required for your current plan (HTTPS)";
|
||||
connectionTestNote.style.color = "#c62828";
|
||||
} else {
|
||||
connectionTestNote.innerText = "Optional for your current plan (HTTP)";
|
||||
connectionTestNote.style.color = "#666";
|
||||
}
|
||||
}
|
||||
|
||||
const currentConfigElem = document.getElementById("current-config");
|
||||
currentConfigElem.innerText = summary.current_config;
|
||||
currentConfigElem.style.color = summary.ssh_success ? "black" : "red";
|
||||
@@ -2558,18 +2572,15 @@ async function migrate(deviceId, ip, method) {
|
||||
}),
|
||||
);
|
||||
|
||||
// Make reboot button available and prominent
|
||||
// Make reboot button available and prominent. It lives in the
|
||||
// always-visible "Speaker controls" row (see #621 — it used to
|
||||
// be reachable only after expanding "Customize this migration"),
|
||||
// so no need to force any collapsed container open here.
|
||||
const rebootBtn = document.getElementById("reboot-speaker-btn");
|
||||
rebootBtn.style.display = "inline-block";
|
||||
rebootBtn.disabled = false;
|
||||
rebootBtn.style.border = "2px solid #000";
|
||||
|
||||
// The Reboot button now lives inside the "Customize this
|
||||
// migration" <details>; expand it so the post-migration
|
||||
// reboot affordance is reachable from the Plan flow too.
|
||||
const customize = rebootBtn.closest("details");
|
||||
if (customize) customize.open = true;
|
||||
|
||||
// Re-show summary but with prominence on reboot
|
||||
summaryDiv.style.display = "block";
|
||||
} else {
|
||||
@@ -2776,6 +2787,26 @@ function onPlanTargetURLChange() {
|
||||
saved.innerText = "✏️ unsaved change — click \"Save as default\" to persist";
|
||||
saved.style.color = "#bf6900";
|
||||
}
|
||||
|
||||
// Re-derive the four service URL fields from the new Target URL, same
|
||||
// as the initial pre-fill on summary render. fillPlanURLInputs still
|
||||
// only overwrites fields the user hasn't hand-edited (tracked via
|
||||
// dataset.autofilled), so this doesn't clobber genuinely manual edits.
|
||||
// Without this, changing Target Domain to e.g. localhost left the four
|
||||
// fields pointed at a stale default with no warning until the user
|
||||
// edited them by hand (#621 follow-up).
|
||||
const soundcork = document.getElementById("plan-soundcork-mode") &&
|
||||
document.getElementById("plan-soundcork-mode").checked;
|
||||
fillPlanURLInputs(defaultServiceURLs(v, {soundcorkMode: soundcork}));
|
||||
}
|
||||
|
||||
// onPlanURLFieldEdited marks a Plan-card URL input as manually edited so
|
||||
// fillPlanURLInputs stops treating it as an auto-fillable default, then
|
||||
// re-validates. Wired from each of the four fields' oninput instead of
|
||||
// calling validatePlanURLs() directly.
|
||||
function onPlanURLFieldEdited(el) {
|
||||
el.dataset.autofilled = "";
|
||||
validatePlanURLs();
|
||||
}
|
||||
|
||||
// saveTargetURLAsDefault posts the current plan-target-url value to
|
||||
@@ -2838,8 +2869,12 @@ function defaultServiceURLs(targetUrl, options = {}) {
|
||||
|
||||
// fillPlanURLInputs writes the four URLs into the Plan card inputs.
|
||||
// force=true overwrites existing values (used by Reset and the
|
||||
// Soundcork toggle); force=false only fills empties (used on summary
|
||||
// render so manual edits survive a refresh).
|
||||
// Soundcork toggle); force=false only fills empties and fields still
|
||||
// flagged dataset.autofilled=true (used on summary render and on Target
|
||||
// URL changes, so manual edits survive but a still-default value tracks
|
||||
// Target URL). Every field this function writes to is (re-)flagged
|
||||
// autofilled; onPlanURLFieldEdited clears the flag the moment a user
|
||||
// types into a field directly.
|
||||
function fillPlanURLInputs(urls, {force = false} = {}) {
|
||||
const fields = [
|
||||
["plan-marge-url", urls.marge],
|
||||
@@ -2850,7 +2885,10 @@ function fillPlanURLInputs(urls, {force = false} = {}) {
|
||||
for (const [id, value] of fields) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) continue;
|
||||
if (force || !el.value) el.value = value;
|
||||
if (force || !el.value || el.dataset.autofilled === "true") {
|
||||
el.value = value;
|
||||
el.dataset.autofilled = "true";
|
||||
}
|
||||
}
|
||||
validatePlanURLs();
|
||||
}
|
||||
@@ -2882,7 +2920,16 @@ function readPlanURLOptions() {
|
||||
// on the speaker itself). For the typical "AfterTouch on a separate
|
||||
// host" deployment, the speaker can't reach loopback on a different
|
||||
// machine, so the URL must be a LAN-reachable IP or hostname.
|
||||
function validateURL(value) {
|
||||
//
|
||||
// referenceOrigin (optional) is the plan's own Target URL origin. A
|
||||
// loopback value that matches it is exempted from the warning: it means
|
||||
// this is exactly what the service itself is already configured to
|
||||
// answer as (e.g. an on-device install's `http://localhost:8000`,
|
||||
// auto-set since #546), not a mistaken paste. Without this exemption,
|
||||
// every on-device install's Suggested Plan fails validation by
|
||||
// default and silently disables Apply/Pre-flight before the user does
|
||||
// anything (#546 follow-up, reported via #621).
|
||||
function validateURL(value, referenceOrigin) {
|
||||
const v = (value || "").trim();
|
||||
if (!v) return {ok: true, error: ""};
|
||||
|
||||
@@ -2899,7 +2946,8 @@ function validateURL(value) {
|
||||
|
||||
if (!u.hostname) return {ok: false, error: "hostname is empty"};
|
||||
|
||||
if (u.hostname === "localhost" || u.hostname === "127.0.0.1") {
|
||||
const isLoopback = u.hostname === "localhost" || u.hostname === "127.0.0.1";
|
||||
if (isLoopback && u.origin !== referenceOrigin) {
|
||||
return {ok: false, error: "loopback URL — speakers can only reach this if AfterTouch is installed on the speaker itself (on-device install). For the typical multi-device setup, use a LAN-reachable IP or hostname."};
|
||||
}
|
||||
|
||||
@@ -2918,12 +2966,23 @@ function validatePlanURLs() {
|
||||
["bmxRegistryUrl", "plan-bmx-url"],
|
||||
];
|
||||
|
||||
const targetUrl = (document.getElementById("plan-target-url") || {}).value || "";
|
||||
let referenceOrigin = "";
|
||||
try {
|
||||
referenceOrigin = new URL(targetUrl).origin;
|
||||
} catch (e) {
|
||||
// Target URL isn't a valid absolute URL yet (e.g. empty) — leave
|
||||
// referenceOrigin empty, so a loopback field simply won't match
|
||||
// it and falls back to today's warning, same as before this
|
||||
// exemption existed.
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
|
||||
for (const [name, elemId] of fields) {
|
||||
const el = document.getElementById(elemId);
|
||||
if (!el) continue;
|
||||
const v = validateURL(el.value);
|
||||
const v = validateURL(el.value, referenceOrigin);
|
||||
el.style.borderColor = v.ok ? "" : "#c62828";
|
||||
if (!v.ok) errors.push(`${name}: ${v.error}`);
|
||||
}
|
||||
@@ -3781,7 +3840,11 @@ function looksTransient(msg) {
|
||||
// DNS interception, CA/TLS), and preconditions (remote_services,
|
||||
// pairing, backup). Reads only fields the backend already exposes —
|
||||
// is_migrated remains the OR of the per-axis booleans.
|
||||
function renderMigrationState(summary) {
|
||||
//
|
||||
// targetUrl is the current Target Domain value, used only to judge
|
||||
// whether CA/TLS is actually relevant to the current plan (see
|
||||
// isHttpsTarget) — the default Suggested Plan never needs it.
|
||||
function renderMigrationState(summary, targetUrl) {
|
||||
// --- Transports ---
|
||||
setStateChip("state-ssh", summary.ssh_success, "Reachable", "Unreachable");
|
||||
setStateChip("state-telnet", summary.telnet_reachable, "Reachable", "Unreachable");
|
||||
@@ -3862,16 +3925,19 @@ function renderMigrationState(summary) {
|
||||
const caLine = document.getElementById("state-ca-line");
|
||||
if (caLine) {
|
||||
caLine.replaceChildren();
|
||||
const v = caVerdict(summary);
|
||||
const v = caVerdict(summary, isHttpsTarget(targetUrl));
|
||||
caLine.appendChild(stateLine(v.icon, v.text, v.note));
|
||||
}
|
||||
|
||||
// --- Preconditions ---
|
||||
const remoteCell = document.getElementById("state-remote-services-cell");
|
||||
if (remoteCell) {
|
||||
remoteCell.replaceChildren();
|
||||
// Like CA/TLS above, the cell also hosts the Enable/Disable SSH
|
||||
// buttons as siblings of this line — only rewrite the verdict span so
|
||||
// they stay put across re-renders.
|
||||
const remoteLine = document.getElementById("state-remote-services-line");
|
||||
if (remoteLine) {
|
||||
remoteLine.replaceChildren();
|
||||
const v = remoteServicesVerdict(summary);
|
||||
remoteCell.appendChild(stateLine(v.icon, v.text, v.note));
|
||||
remoteLine.appendChild(stateLine(v.icon, v.text, v.note));
|
||||
}
|
||||
|
||||
const pairedCell = document.getElementById("state-paired");
|
||||
@@ -3993,9 +4059,22 @@ function dnsInterceptionVerdict(summary) {
|
||||
return {icon: "⚠️", text: "/etc/hosts redirects", note: "(deprecated method)"};
|
||||
}
|
||||
|
||||
function caVerdict(summary) {
|
||||
// isHttpsTarget reports whether a target/service URL uses the https
|
||||
// scheme. Used to distinguish "CA/TLS optional" (the default Suggested
|
||||
// Plan for both XML-over-SSH and Telnet migrates over plain HTTP, no CA
|
||||
// involved) from "CA/TLS required" (Target Domain is https://, or the
|
||||
// Customize form's DNS-interception method is chosen — that one always
|
||||
// targets https://*.bose.com).
|
||||
function isHttpsTarget(url) {
|
||||
return /^https:/i.test((url || "").trim());
|
||||
}
|
||||
|
||||
function caVerdict(summary, httpsRelevant) {
|
||||
if (summary.ca_cert_trusted) return {icon: "✅", text: "Local root CA installed", note: ""};
|
||||
return {icon: "❌", text: "Not installed", note: "(HTTPS to local service will fail TLS validation until injected via SSH)"};
|
||||
if (httpsRelevant) {
|
||||
return {icon: "❌", text: "Not installed", note: "(required — your Target URL is HTTPS; install it before migrating, or click Trust CA Now)"};
|
||||
}
|
||||
return {icon: "⚪", text: "Not installed", note: "(not needed — your Target URL is HTTP; only required if you switch to HTTPS or use the DNS-interception method)"};
|
||||
}
|
||||
|
||||
function remoteServicesVerdict(summary) {
|
||||
|
||||
@@ -183,6 +183,47 @@ func (m *Manager) setBoseURLsViaTelnet(deviceIP, marge, swUpdate string) (string
|
||||
return logs.String(), nil
|
||||
}
|
||||
|
||||
// setAllBoseURLsViaTelnet writes all four boseurls (bmx, stats, marge,
|
||||
// swUpdate) to the runtime layer via `sys configuration ...`, then commits
|
||||
// them with `envswitch boseurls set`, over the port-17000 shell. Unlike
|
||||
// setBoseURLsViaTelnet (which only issues the envswitch commit, used by the
|
||||
// #471 SSH-bootstrap/reset flows that need that specific two-argument
|
||||
// injection), this mirrors telnetURLs.Commands()'s full sequence so the
|
||||
// envswitch commit captures fresh values for all four fields, not just two.
|
||||
func (m *Manager) setAllBoseURLsViaTelnet(deviceIP string, urls telnetURLs) (string, error) {
|
||||
if m.NewTelnet == nil {
|
||||
return "", errors.New("telnet not configured: Manager.NewTelnet is nil")
|
||||
}
|
||||
|
||||
var logs strings.Builder
|
||||
|
||||
t := m.NewTelnet(deviceIP)
|
||||
if err := t.Dial(); err != nil {
|
||||
return logs.String(), fmt.Errorf("telnet dial %s:17000: %w", deviceIP, err)
|
||||
}
|
||||
|
||||
defer func() { _ = t.Close() }()
|
||||
|
||||
if banner, _ := t.Probe(); banner != "" {
|
||||
fmt.Fprintf(&logs, "Telnet banner: %q\n", strings.TrimSpace(banner))
|
||||
}
|
||||
|
||||
for _, cmd := range urls.Commands() {
|
||||
resp, err := t.SendCommand(cmd)
|
||||
if err != nil {
|
||||
return logs.String(), fmt.Errorf("telnet command %q failed: %w", cmd, err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&logs, "→ %s\n%s\n", cmd, strings.TrimRight(resp, "\r\n"))
|
||||
|
||||
if isCommandNotFound(resp) {
|
||||
return logs.String(), fmt.Errorf("device rejected %q (firmware does not expose this command)", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
return logs.String(), nil
|
||||
}
|
||||
|
||||
// fwScript is the speaker's persistent iptables script; appending here makes a
|
||||
// rule survive reboot (it is re-applied on boot).
|
||||
const fwScript = "/etc/init.d/Firewalls/update_iptables"
|
||||
|
||||
@@ -157,6 +157,59 @@ func TestSetBoseURLs_RejectsDoubleQuote(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetAllBoseURLsViaTelnet_WritesAllFourBeforeEnvswitch is the regression
|
||||
// test for the stale statsServerUrl/bmxRegistryUrl bug reported in #621: the
|
||||
// XML migration's telnet resync used to commit `envswitch boseurls set` with
|
||||
// only marge/swUpdate as arguments, silently freezing whatever stats/bmx
|
||||
// happened to still be in the runtime layer at that moment. This asserts all
|
||||
// four `sys configuration` writes land before the single `envswitch` commit,
|
||||
// matching telnetURLs.Commands()'s known-good sequence.
|
||||
func TestSetAllBoseURLsViaTelnet_WritesAllFourBeforeEnvswitch(t *testing.T) {
|
||||
const targetURL = "http://localhost:8000"
|
||||
|
||||
urls := telnetURLs{
|
||||
Marge: targetURL,
|
||||
Stats: targetURL,
|
||||
SwUpdate: targetURL + "/updates/soundtouch",
|
||||
BmxRegistry: targetURL + "/bmx/registry/v1/services",
|
||||
}
|
||||
|
||||
want := urls.Commands()
|
||||
|
||||
resp := make(map[string]string, len(want))
|
||||
for _, c := range want {
|
||||
resp[c] = "OK\n"
|
||||
}
|
||||
|
||||
f := &fakeTelnet{responses: resp}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
if _, err := m.setAllBoseURLsViaTelnet("192.0.2.10", urls); err != nil {
|
||||
t.Fatalf("setAllBoseURLsViaTelnet: %v", err)
|
||||
}
|
||||
|
||||
if len(f.commands) != len(want) {
|
||||
t.Fatalf("sent %d commands %q\n want %d %q", len(f.commands), f.commands, len(want), want)
|
||||
}
|
||||
|
||||
for i, c := range want {
|
||||
if f.commands[i] != c {
|
||||
t.Errorf("command %d = %q\n want %q", i, f.commands[i], c)
|
||||
}
|
||||
}
|
||||
|
||||
envswitchIdx := len(want) - 1
|
||||
for i, c := range f.commands[:envswitchIdx] {
|
||||
if !strings.HasPrefix(c, "sys configuration ") {
|
||||
t.Errorf("command %d = %q, want a `sys configuration ...` runtime write before the envswitch commit", i, c)
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(f.commands[envswitchIdx], "envswitch boseurls set ") {
|
||||
t.Errorf("last command = %q, want the envswitch commit last", f.commands[envswitchIdx])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClose17000_RunsFirewallSteps(t *testing.T) {
|
||||
var ran []string
|
||||
|
||||
|
||||
@@ -225,6 +225,82 @@ func (m *Manager) postSetMargeAccount(deviceIP, accountID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfigurationStatus values reported by GET /soundTouchConfigurationStatus.
|
||||
// See issue #615: a speaker can be reachable, named, and already
|
||||
// account-paired yet still report SOUNDTOUCH_NOT_CONFIGURED, which leaves
|
||||
// the firmware nagging the owner to install the Bose app. Only a full pass
|
||||
// through the WebSocket setup state machine (ExecuteInitPlan) clears it.
|
||||
const (
|
||||
ConfigurationStatusConfigured = "SOUNDTOUCH_CONFIGURED"
|
||||
ConfigurationStatusNotConfigured = "SOUNDTOUCH_NOT_CONFIGURED"
|
||||
)
|
||||
|
||||
// ReadConfigurationStatus fetches /soundTouchConfigurationStatus and returns
|
||||
// its raw status attribute (e.g. "SOUNDTOUCH_CONFIGURED").
|
||||
func (m *Manager) ReadConfigurationStatus(deviceIP string) (string, error) {
|
||||
url := buildDeviceURL(deviceIP, "/soundTouchConfigurationStatus")
|
||||
|
||||
client := &http.Client{Timeout: supportedURLsTimeout}
|
||||
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("GET %s: %w", url, err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("GET %s returned %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s: %w", url, err)
|
||||
}
|
||||
|
||||
var doc struct {
|
||||
Status string `xml:"status,attr"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(body, &doc); err != nil {
|
||||
return "", fmt.Errorf("parse %s: %w", url, err)
|
||||
}
|
||||
|
||||
return doc.Status, nil
|
||||
}
|
||||
|
||||
// PreflightInitPlan reports whether ExecuteInitPlan should be run against
|
||||
// deviceIP, gated on the two conditions from issue #615: /setMargeAccount
|
||||
// must be listed in /supportedURLs, and the device's current
|
||||
// /soundTouchConfigurationStatus must be exactly SOUNDTOUCH_NOT_CONFIGURED.
|
||||
// needed=false with a nil error means "already configured, nothing to do."
|
||||
// Any other outcome (unsupported route, unrecognised status value) is
|
||||
// treated as unknown and returned as an error rather than guessed at.
|
||||
func (m *Manager) PreflightInitPlan(deviceIP string) (needed bool, status string, err error) {
|
||||
supported, probeErr := m.probeSetMargeAccount(deviceIP)
|
||||
if probeErr != nil {
|
||||
return false, "", fmt.Errorf("supportedURLs probe: %w", probeErr)
|
||||
}
|
||||
|
||||
if !supported {
|
||||
return false, "", errors.New("/setMargeAccount is not listed in /supportedURLs — device does not support this pairing path")
|
||||
}
|
||||
|
||||
status, err = m.ReadConfigurationStatus(deviceIP)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("read /soundTouchConfigurationStatus: %w", err)
|
||||
}
|
||||
|
||||
switch status {
|
||||
case ConfigurationStatusConfigured:
|
||||
return false, status, nil
|
||||
case ConfigurationStatusNotConfigured:
|
||||
return true, status, nil
|
||||
default:
|
||||
return false, status, fmt.Errorf("unexpected /soundTouchConfigurationStatus value %q", status)
|
||||
}
|
||||
}
|
||||
|
||||
// buildDeviceURL builds a URL for a SoundTouch device's HTTP API. If
|
||||
// deviceIP already includes a port (test scenarios using httptest) it is
|
||||
// reused as-is; otherwise the canonical port 8090 is appended.
|
||||
|
||||
@@ -16,13 +16,14 @@ import (
|
||||
// device's :8090 HTTP API. It records POSTs to /setMargeAccount so tests
|
||||
// can assert on the body.
|
||||
type fakeDevice struct {
|
||||
srv *httptest.Server
|
||||
addr string // "host:port" usable as deviceIP
|
||||
supportsSetMarge bool
|
||||
postStatus int // status code returned for POST /setMargeAccount
|
||||
postDelay time.Duration
|
||||
gotPostBody string
|
||||
margeAccountUUID string // served by /info; empty means "unpaired"
|
||||
srv *httptest.Server
|
||||
addr string // "host:port" usable as deviceIP
|
||||
supportsSetMarge bool
|
||||
postStatus int // status code returned for POST /setMargeAccount
|
||||
postDelay time.Duration
|
||||
gotPostBody string
|
||||
margeAccountUUID string // served by /info; empty means "unpaired"
|
||||
configurationStatus string // served by /soundTouchConfigurationStatus; empty = route not served (404)
|
||||
}
|
||||
|
||||
func newFakeDevice(t *testing.T) *fakeDevice {
|
||||
@@ -62,6 +63,16 @@ func newFakeDevice(t *testing.T) *fakeDevice {
|
||||
fmt.Fprintf(w, `<info deviceID="AABBCCDDEE0A"><margeAccountUUID>%s</margeAccountUUID></info>`, d.margeAccountUUID)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/soundTouchConfigurationStatus", func(w http.ResponseWriter, _ *http.Request) {
|
||||
if d.configurationStatus == "" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprintf(w, `<SoundTouchConfigurationStatus status="%s" />`, d.configurationStatus)
|
||||
})
|
||||
|
||||
d.srv = httptest.NewServer(mux)
|
||||
|
||||
u := d.srv.URL[len("http://"):]
|
||||
@@ -360,6 +371,110 @@ func TestEnsureMargeAccountPaired_PropagatesPairingFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadConfigurationStatus_ReturnsRawStatus(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.configurationStatus = ConfigurationStatusConfigured
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
status, err := m.ReadConfigurationStatus(d.addr)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadConfigurationStatus: %v", err)
|
||||
}
|
||||
|
||||
if status != ConfigurationStatusConfigured {
|
||||
t.Errorf("status = %q, want %q", status, ConfigurationStatusConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadConfigurationStatus_ErrorsWhenRouteUnsupported(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.configurationStatus = ""
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
if _, err := m.ReadConfigurationStatus(d.addr); err == nil {
|
||||
t.Fatal("expected an error when the route is unsupported (404)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightInitPlan_NotConfiguredNeedsRepair(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.configurationStatus = ConfigurationStatusNotConfigured
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
needed, status, err := m.PreflightInitPlan(d.addr)
|
||||
if err != nil {
|
||||
t.Fatalf("PreflightInitPlan: %v", err)
|
||||
}
|
||||
|
||||
if !needed {
|
||||
t.Error("needed should be true for SOUNDTOUCH_NOT_CONFIGURED")
|
||||
}
|
||||
|
||||
if status != ConfigurationStatusNotConfigured {
|
||||
t.Errorf("status = %q, want %q", status, ConfigurationStatusNotConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightInitPlan_AlreadyConfiguredIsNoOp(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.configurationStatus = ConfigurationStatusConfigured
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
needed, status, err := m.PreflightInitPlan(d.addr)
|
||||
if err != nil {
|
||||
t.Fatalf("PreflightInitPlan: %v", err)
|
||||
}
|
||||
|
||||
if needed {
|
||||
t.Error("needed should be false for SOUNDTOUCH_CONFIGURED")
|
||||
}
|
||||
|
||||
if status != ConfigurationStatusConfigured {
|
||||
t.Errorf("status = %q, want %q", status, ConfigurationStatusConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightInitPlan_UnsupportedSetMargeAccountFailsClosed(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.supportsSetMarge = false
|
||||
d.configurationStatus = ConfigurationStatusNotConfigured
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
needed, _, err := m.PreflightInitPlan(d.addr)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when /setMargeAccount is not listed in /supportedURLs")
|
||||
}
|
||||
|
||||
if needed {
|
||||
t.Error("needed should be false when preflight fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightInitPlan_UnrecognisedStatusFailsClosed(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.configurationStatus = "SOMETHING_UNEXPECTED"
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
needed, status, err := m.PreflightInitPlan(d.addr)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an unrecognised status value")
|
||||
}
|
||||
|
||||
if needed {
|
||||
t.Error("needed should be false when the status is unrecognised")
|
||||
}
|
||||
|
||||
if status != "SOMETHING_UNEXPECTED" {
|
||||
t.Errorf("status = %q, want the raw unrecognised value returned alongside the error", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidAccountID(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
|
||||
@@ -124,9 +124,17 @@ type MigrationSummary struct {
|
||||
}
|
||||
|
||||
// SSHClient defines the interface for SSH operations.
|
||||
//
|
||||
// Connect/Close are optional: Run/UploadContent both work standalone
|
||||
// (dialing their own one-off connection each time, as they always have).
|
||||
// Call Connect first when making several calls in a row — e.g.
|
||||
// RevertMigration's ~17 commands — so they reuse one connection instead of
|
||||
// dialing fresh every time; defer Close to release it afterward.
|
||||
type SSHClient interface {
|
||||
Run(command string) (string, error)
|
||||
UploadContent(content []byte, remotePath string) error
|
||||
Connect() error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// TelnetClient defines the interface for the device's port-17000 diagnostic
|
||||
@@ -1081,32 +1089,46 @@ func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options ma
|
||||
}
|
||||
}
|
||||
|
||||
logs += m.resyncBoseURLsAfterXML(deviceIP, cfg.MargeServerUrl, cfg.SwUpdateUrl)
|
||||
logs += m.resyncBoseURLsAfterXML(deviceIP, telnetURLs{
|
||||
Marge: cfg.MargeServerUrl,
|
||||
Stats: cfg.StatsServerUrl,
|
||||
SwUpdate: cfg.SwUpdateUrl,
|
||||
BmxRegistry: cfg.BmxRegistryUrl,
|
||||
})
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
// resyncBoseURLsAfterXML re-applies the boseurls over telnet so the runtime
|
||||
// URL layer matches the XML just written by migrateViaXML.
|
||||
// resyncBoseURLsAfterXML re-applies all four boseurls over telnet so the
|
||||
// runtime URL layer matches the XML just written by migrateViaXML.
|
||||
//
|
||||
// The XML migration only updates the persisted SoundTouchSdkPrivateCfg.xml; it
|
||||
// does not touch the runtime/persistence layer that `getpdo
|
||||
// CurrentSystemConfiguration` reports. When SSH was bootstrapped via #471
|
||||
// (`enable-ssh`), that layer still points at the placeholder boseurls
|
||||
// (https://aftertouch.invalid), so the preflight cross-check keeps warning that
|
||||
// margeServerUrl/swUpdateUrl differ between transports until a reboot.
|
||||
// Re-applying the real boseurls over telnet :17000 reconciles it immediately.
|
||||
// the URLs differ between transports until a reboot.
|
||||
//
|
||||
// All four fields are re-applied, not just marge/swUpdate: the closing
|
||||
// `envswitch boseurls set` commit persists whatever is currently in the
|
||||
// runtime layer at the moment it runs, not only its own two arguments (see
|
||||
// docs/content/docs/analysis/TELNET-COMMAND-REFERENCE.md). Committing while
|
||||
// stats/bmx are still stale in the runtime layer freezes those stale values
|
||||
// into the persistence layer permanently — a later reboot loads that frozen
|
||||
// persistence layer, not the XML file, so nothing short of a factory reset
|
||||
// clears it again. Re-applying the real boseurls over telnet :17000
|
||||
// reconciles all four immediately.
|
||||
//
|
||||
// Best-effort: telnet may be unavailable (no port 17000, or it was closed via
|
||||
// --close-17000), in which case a reboot still reconciles the layers, so this
|
||||
// only returns a note and never fails the migration. Returns the log lines to
|
||||
// append.
|
||||
func (m *Manager) resyncBoseURLsAfterXML(deviceIP, marge, swUpdate string) string {
|
||||
func (m *Manager) resyncBoseURLsAfterXML(deviceIP string, urls telnetURLs) string {
|
||||
if m.NewTelnet == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
rlogs, rerr := m.setBoseURLsViaTelnet(deviceIP, marge, swUpdate)
|
||||
rlogs, rerr := m.setAllBoseURLsViaTelnet(deviceIP, urls)
|
||||
if rerr != nil {
|
||||
return fmt.Sprintf("Note: could not re-sync boseurls over telnet (%v); a device reboot will reconcile the runtime layer.\n", rerr)
|
||||
}
|
||||
@@ -1869,6 +1891,18 @@ func (m *Manager) patchUdhcpcScript(client SSHClient, targetScript, hookMarker s
|
||||
// RevertMigration reverts the speaker to its original Bose cloud configuration.
|
||||
func (m *Manager) RevertMigration(deviceIP string) (string, error) {
|
||||
client := m.NewSSH(deviceIP)
|
||||
|
||||
// This function alone makes ~17 client.Run/UploadContent calls across
|
||||
// its sub-steps below. Dialing a fresh SSH connection per call (the
|
||||
// default when Connect isn't used) was confirmed on real hardware to
|
||||
// overwhelm a resource-constrained speaker; Connect+defer Close keeps
|
||||
// it to one connection for the whole revert instead.
|
||||
if err := client.Connect(); err != nil {
|
||||
return "", fmt.Errorf("failed to connect for revert: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
rwCmd := "(rw || mount -o remount,rw /)"
|
||||
|
||||
var logs string
|
||||
|
||||
@@ -132,6 +132,12 @@ func (m *mockSSH) UploadContent(content []byte, remotePath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Connect/Close are no-ops here — the mock has no real connection to
|
||||
// reuse, and every test call already goes through Run/UploadContent above
|
||||
// regardless of whether Connect was called first.
|
||||
func (m *mockSSH) Connect() error { return nil }
|
||||
func (m *mockSSH) Close() error { return nil }
|
||||
|
||||
func TestMigrateViaHosts(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "setup-test")
|
||||
if err != nil {
|
||||
@@ -2096,25 +2102,41 @@ func TestMigrateViaXML_ReappliesBoseURLsOverTelnet(t *testing.T) {
|
||||
return &mockSSH{runFunc: func(string) (string, error) { return "", nil }}
|
||||
}
|
||||
|
||||
ft := &fakeTelnet{banner: "->", responses: map[string]string{}}
|
||||
wantCmds := telnetURLs{
|
||||
Marge: target,
|
||||
Stats: target,
|
||||
SwUpdate: target + "/updates/soundtouch",
|
||||
BmxRegistry: target + "/bmx/registry/v1/services",
|
||||
}.Commands()
|
||||
|
||||
resp := make(map[string]string, len(wantCmds))
|
||||
for _, c := range wantCmds {
|
||||
resp[c] = "OK\n"
|
||||
}
|
||||
|
||||
ft := &fakeTelnet{banner: "->", responses: resp}
|
||||
m.NewTelnet = func(string) TelnetClient { return ft }
|
||||
|
||||
if _, err := m.MigrateSpeaker("192.0.2.10", target, "", nil, MigrationMethodXML); err != nil {
|
||||
t.Fatalf("MigrateSpeaker: %v", err)
|
||||
}
|
||||
|
||||
want := `envswitch boseurls set "` + target + `" "` + target + `/updates/soundtouch"`
|
||||
|
||||
var found bool
|
||||
for _, c := range ft.commands {
|
||||
if c == want {
|
||||
found = true
|
||||
break
|
||||
// All four `sys configuration` writes must land before the envswitch
|
||||
// commit — see enable_ssh.go's setAllBoseURLsViaTelnet — otherwise the
|
||||
// commit freezes whatever stale value was still in the runtime layer for
|
||||
// any field not passed to it (the #621 statsServerUrl/bmxRegistryUrl bug).
|
||||
for _, want := range wantCmds {
|
||||
var found bool
|
||||
for _, c := range ft.commands {
|
||||
if c == want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("expected boseurls re-apply %q after XML migration; sent: %v", want, ft.commands)
|
||||
if !found {
|
||||
t.Errorf("expected boseurls re-apply command %q after XML migration; sent: %v", want, ft.commands)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package soundtouchweb
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
)
|
||||
|
||||
// autoResumeBackoff is the delay before re-issuing a dropped content item,
|
||||
// giving a transient upstream hiccup a moment to clear before retrying.
|
||||
const autoResumeBackoff = 2 * time.Second
|
||||
|
||||
// autoResumeState tracks what ConnectDeviceWebSocket needs to decide whether
|
||||
// a now_playing transition should trigger an auto-resume. Split out from the
|
||||
// WebSocket goroutine so the decision can be unit tested without a live
|
||||
// connection.
|
||||
//
|
||||
// resumeAttempts only labels log lines — it is never used to cap retries.
|
||||
// A resume is gated on wasError being false (see observe), which already
|
||||
// means at most one attempt ever fires per drop: if the attempt fails and
|
||||
// the source stays in error, every following event has wasError=true and
|
||||
// nothing fires again until a genuine recovery is observed. A station that
|
||||
// keeps recovering and re-dropping (the reported #622 pattern — a TuneIn
|
||||
// stream disconnecting the speaker on a fixed cycle, indefinitely, while
|
||||
// otherwise healthy) is exactly the case this should keep resuming forever.
|
||||
type autoResumeState struct {
|
||||
lastGoodContentItem *models.ContentItem
|
||||
resumeAttempts int
|
||||
}
|
||||
|
||||
// observe updates the state for a new now_playing event and reports whether
|
||||
// the caller should fire an auto-resume for item, plus a label for the log
|
||||
// line. prevSource is the source seen on the previous event.
|
||||
//
|
||||
// #622: some TuneIn stations disconnect the speaker's audio pipeline on
|
||||
// their own (errorUpdate 1041 SOURCE_DISCONNECTED, observed ~5m35s into
|
||||
// playback on one reporter's setup) even though the SoundTouch WebSocket
|
||||
// control channel stays healthy throughout. The firmware does not recover
|
||||
// on its own, so a fresh transition into an error source right after a
|
||||
// healthy one — the speaker dropping a source it didn't choose to leave, as
|
||||
// opposed to the user picking a new one — re-issues the last content item,
|
||||
// exactly what pressing the physical preset button again does.
|
||||
func (s *autoResumeState) observe(prevSource string, np *models.NowPlaying) (item *models.ContentItem, attempt int, shouldResume bool) {
|
||||
wasError := isErrorSource(prevSource)
|
||||
nowError := isErrorSource(np.Source)
|
||||
|
||||
if !nowError {
|
||||
if np.ContentItem != nil {
|
||||
s.lastGoodContentItem = np.ContentItem
|
||||
}
|
||||
|
||||
return nil, 0, false
|
||||
}
|
||||
|
||||
if wasError || s.lastGoodContentItem == nil {
|
||||
return nil, 0, false
|
||||
}
|
||||
|
||||
s.resumeAttempts++
|
||||
|
||||
return s.lastGoodContentItem, s.resumeAttempts, true
|
||||
}
|
||||
|
||||
// autoResumePlayback re-selects item on conn's device after autoResumeBackoff.
|
||||
// It runs in its own goroutine (never on the WebSocket read loop) so a slow
|
||||
// or hanging /select call can't stall processing of further device events.
|
||||
func autoResumePlayback(conn *webtypes.DeviceConnection, deviceID string, item *models.ContentItem, attempt int) {
|
||||
autoResumePlaybackAfter(conn, deviceID, item, attempt, autoResumeBackoff)
|
||||
}
|
||||
|
||||
// autoResumePlaybackAfter is autoResumePlayback with an injectable delay so
|
||||
// tests don't have to wait out the real backoff.
|
||||
func autoResumePlaybackAfter(conn *webtypes.DeviceConnection, deviceID string, item *models.ContentItem, attempt int, delay time.Duration) {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-conn.Done():
|
||||
return
|
||||
}
|
||||
|
||||
if conn.Client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := conn.Client.SelectContentItem(item); err != nil {
|
||||
log.Printf("[play] device=%q auto-resume attempt %d failed: %v",
|
||||
sanitizeLog(deviceID), attempt, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[play] device=%q auto-resume attempt %d re-selected source=%q location=%q",
|
||||
sanitizeLog(deviceID), attempt, sanitizeLog(item.Source), sanitizeLog(item.Location))
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package soundtouchweb
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
)
|
||||
|
||||
func tuneInNowPlaying(source string) *models.NowPlaying {
|
||||
return &models.NowPlaying{
|
||||
Source: source,
|
||||
ContentItem: &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: "stationurl",
|
||||
Location: "/v1/playback/station/s119025",
|
||||
ItemName: "Arabella Lovesongs",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoResumeState_HealthyRemembersContentItemAndDoesNotResume(t *testing.T) {
|
||||
s := &autoResumeState{}
|
||||
|
||||
item, attempt, shouldResume := s.observe("", tuneInNowPlaying("TUNEIN"))
|
||||
if shouldResume {
|
||||
t.Fatalf("shouldResume = true on a healthy source, want false")
|
||||
}
|
||||
|
||||
if item != nil || attempt != 0 {
|
||||
t.Errorf("item/attempt = %v/%d, want nil/0", item, attempt)
|
||||
}
|
||||
|
||||
if s.lastGoodContentItem == nil {
|
||||
t.Fatal("lastGoodContentItem was not recorded from a healthy now_playing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoResumeState_FreshErrorAfterHealthyTriggersResume(t *testing.T) {
|
||||
s := &autoResumeState{}
|
||||
|
||||
// Prime with a healthy TUNEIN event, matching the WS handler calling
|
||||
// observe once per event with the source seen on the previous call.
|
||||
s.observe("", tuneInNowPlaying("TUNEIN"))
|
||||
|
||||
item, attempt, shouldResume := s.observe("TUNEIN", tuneInNowPlaying("INVALID_SOURCE"))
|
||||
if !shouldResume {
|
||||
t.Fatal("shouldResume = false on a fresh error transition, want true")
|
||||
}
|
||||
|
||||
if attempt != 1 {
|
||||
t.Errorf("attempt = %d, want 1", attempt)
|
||||
}
|
||||
|
||||
if item == nil || item.Location != "/v1/playback/station/s119025" {
|
||||
t.Errorf("item = %+v, want the last healthy ContentItem", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoResumeState_DoesNotResumeWithoutAPriorGoodContentItem(t *testing.T) {
|
||||
s := &autoResumeState{}
|
||||
|
||||
// No healthy event was ever observed, so there's nothing to restore.
|
||||
_, _, shouldResume := s.observe("", tuneInNowPlaying("INVALID_SOURCE"))
|
||||
if shouldResume {
|
||||
t.Fatal("shouldResume = true with no prior good ContentItem, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoResumeState_DoesNotResumeOnRepeatedErrorEvents(t *testing.T) {
|
||||
s := &autoResumeState{}
|
||||
|
||||
s.observe("", tuneInNowPlaying("TUNEIN"))
|
||||
s.observe("TUNEIN", tuneInNowPlaying("INVALID_SOURCE")) // first resume, attempt 1
|
||||
|
||||
// A second consecutive error event (wasError=true this time) must not
|
||||
// fire another resume — one attempt per drop, not per event.
|
||||
_, _, shouldResume := s.observe("INVALID_SOURCE", tuneInNowPlaying("INVALID_SOURCE"))
|
||||
if shouldResume {
|
||||
t.Fatal("shouldResume = true on a repeated error event, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoResumeState_KeepsResumingIndefinitelyAcrossRepeatedDrops(t *testing.T) {
|
||||
s := &autoResumeState{}
|
||||
|
||||
s.observe("", tuneInNowPlaying("TUNEIN"))
|
||||
|
||||
// The reported #622 pattern: the same station drops and (once resumed)
|
||||
// recovers repeatedly, indefinitely, on a fixed cycle. Each fresh drop
|
||||
// after a genuine recovery must keep resuming — there is no cap.
|
||||
const cycles = 20
|
||||
|
||||
for i := 1; i <= cycles; i++ {
|
||||
_, attempt, shouldResume := s.observe("TUNEIN", tuneInNowPlaying("INVALID_SOURCE"))
|
||||
if !shouldResume {
|
||||
t.Fatalf("cycle %d: shouldResume = false, want true", i)
|
||||
}
|
||||
|
||||
if attempt != i {
|
||||
t.Errorf("cycle %d: attempt label = %d, want %d", i, attempt, i)
|
||||
}
|
||||
|
||||
s.observe("INVALID_SOURCE", tuneInNowPlaying("TUNEIN")) // the resume worked
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoResumeState_StopsRetryingAfterAFailedResume(t *testing.T) {
|
||||
s := &autoResumeState{}
|
||||
|
||||
s.observe("", tuneInNowPlaying("TUNEIN"))
|
||||
|
||||
_, _, shouldResume := s.observe("TUNEIN", tuneInNowPlaying("INVALID_SOURCE"))
|
||||
if !shouldResume {
|
||||
t.Fatal("shouldResume = false on the first drop, want true")
|
||||
}
|
||||
|
||||
// The resume attempt itself failed (or the station is genuinely gone):
|
||||
// the speaker keeps reporting the same error source on further events.
|
||||
// wasError is now true, so nothing should fire again without a genuine
|
||||
// recovery in between — this is what keeps a truly dead station from
|
||||
// being retried forever.
|
||||
for i := 0; i < 5; i++ {
|
||||
_, _, shouldResume := s.observe("INVALID_SOURCE", tuneInNowPlaying("INVALID_SOURCE"))
|
||||
if shouldResume {
|
||||
t.Fatalf("iteration %d: shouldResume = true on a persisting error, want false", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoResumePlaybackAfter_ReselectsContentItem(t *testing.T) {
|
||||
speaker, captured := setupSpeakerMock(t, nil)
|
||||
defer speaker.Close()
|
||||
|
||||
c := client.NewClient(&client.Config{Host: speaker.URL})
|
||||
conn := webtypes.NewDeviceConnection(c, &models.DeviceInfo{DeviceID: "DEVICEID01"})
|
||||
|
||||
item := &models.ContentItem{Source: "TUNEIN", Type: "stationurl", Location: "/v1/playback/station/s119025", ItemName: "Arabella Lovesongs"}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
autoResumePlaybackAfter(conn, "DEVICEID01", item, 1, 0)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("autoResumePlaybackAfter did not return in time")
|
||||
}
|
||||
|
||||
body, ok := captured["/select"]
|
||||
if !ok {
|
||||
t.Fatalf("no /select request captured; requests: %v", captured)
|
||||
}
|
||||
|
||||
if !strings.Contains(body, `source="TUNEIN"`) || !strings.Contains(body, "/v1/playback/station/s119025") {
|
||||
t.Errorf("/select body = %q, want it to carry the TUNEIN content item", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoResumePlaybackAfter_StopsWhenConnectionClosed(t *testing.T) {
|
||||
speaker, captured := setupSpeakerMock(t, nil)
|
||||
defer speaker.Close()
|
||||
|
||||
c := client.NewClient(&client.Config{Host: speaker.URL})
|
||||
conn := webtypes.NewDeviceConnection(c, &models.DeviceInfo{DeviceID: "DEVICEID01"})
|
||||
conn.Close()
|
||||
|
||||
item := &models.ContentItem{Source: "TUNEIN", Type: "stationurl", Location: "/v1/playback/station/s119025"}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
autoResumePlaybackAfter(conn, "DEVICEID01", item, 1, time.Hour)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("autoResumePlaybackAfter did not return promptly after conn.Close()")
|
||||
}
|
||||
|
||||
if _, ok := captured["/select"]; ok {
|
||||
t.Error("/select was called after the connection was closed, want no request")
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,15 @@ type WebApp struct {
|
||||
// removal only prunes the in-memory registry).
|
||||
RemoveDeviceHook func(deviceID string) error
|
||||
|
||||
// AutoResumeOnSourceDisconnect, when set and returning true, makes
|
||||
// ConnectDeviceWebSocket re-issue a device's last playing content item
|
||||
// after an unsolicited drop into an error source (#622). Opt-in: the
|
||||
// embedded build wires it to Settings.AutoResumeOnSourceDisconnect
|
||||
// (settings.json, default false); standalone soundtouch-player leaves it
|
||||
// nil, which disables the behaviour. Read once per drop rather than
|
||||
// cached, so toggling the setting takes effect without a restart.
|
||||
AutoResumeOnSourceDisconnect func() bool
|
||||
|
||||
discoveryStatus atomic.Value // stores *webtypes.DiscoveryStatus
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,12 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
|
||||
// error source is logged once per transition into it, not on every event.
|
||||
var prevSource string
|
||||
|
||||
// resumeState survives both the speaker's own WebSocket reconnects and
|
||||
// this loop's outer reconnects (declared once, outside the loop) so an
|
||||
// auto-resume can fire regardless of which layer last re-established
|
||||
// the connection.
|
||||
resumeState := &autoResumeState{}
|
||||
|
||||
for {
|
||||
// Stop if the device was removed from the registry (conn.Close()).
|
||||
select {
|
||||
@@ -189,6 +195,11 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
|
||||
logNowPlayingError(deviceID, np.Source, np.SourceAccount)
|
||||
}
|
||||
|
||||
if item, attempt, shouldResume := resumeState.observe(prevSource, np); shouldResume &&
|
||||
app.AutoResumeOnSourceDisconnect != nil && app.AutoResumeOnSourceDisconnect() {
|
||||
go autoResumePlayback(conn, deviceID, item, attempt)
|
||||
}
|
||||
|
||||
prevSource = np.Source
|
||||
|
||||
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
|
||||
|
||||
+69
-12
@@ -14,6 +14,15 @@ import (
|
||||
type Client struct {
|
||||
Host string
|
||||
User string
|
||||
|
||||
// conn is non-nil once Connect has been called, and is then reused by
|
||||
// Run/UploadContent until Close. Left nil, each Run/UploadContent call
|
||||
// dials its own one-off connection as before — Connect is opt-in for
|
||||
// callers making several calls in a row (e.g. RevertMigration's ~17
|
||||
// commands), where dialing fresh every time is both slow and, on a
|
||||
// resource-constrained speaker, has been observed to overwhelm the
|
||||
// device (#614 self-test, 2026-08-16).
|
||||
conn *ssh.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new SSH client for the given host. The default user is "root".
|
||||
@@ -66,21 +75,71 @@ func (c *Client) getConfig() *ssh.ClientConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// Connect opens a persistent SSH connection reused by subsequent
|
||||
// Run/UploadContent calls, instead of each dialing its own. Call Close when
|
||||
// done with it. Idempotent — calling Connect again while already connected
|
||||
// is a no-op. Skip this for a single (or a rare few) command — dialing
|
||||
// once and reusing it is only worth the extra Close bookkeeping when
|
||||
// several calls follow in quick succession.
|
||||
func (c *Client) Connect() error {
|
||||
if c.conn != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
conn, err := ssh.Dial("tcp", c.Host+":22", c.getConfig())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to dial: %w", err)
|
||||
}
|
||||
|
||||
c.conn = conn
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the persistent connection opened by Connect, if any. Safe
|
||||
// to call even when Connect was never called (e.g. every Run/UploadContent
|
||||
// call so far used its own one-off connection).
|
||||
func (c *Client) Close() error {
|
||||
if c.conn == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := c.conn.Close()
|
||||
c.conn = nil
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// dial returns the persistent connection from Connect if one is open,
|
||||
// otherwise dials a fresh one-off connection for the caller to close via
|
||||
// the returned closeFunc (a no-op when reusing the persistent connection —
|
||||
// that one is only closed by an explicit Close call).
|
||||
func (c *Client) dial() (conn *ssh.Client, closeFunc func(), err error) {
|
||||
if c.conn != nil {
|
||||
return c.conn, func() {}, nil
|
||||
}
|
||||
|
||||
conn, err = ssh.Dial("tcp", c.Host+":22", c.getConfig())
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to dial: %w", err)
|
||||
}
|
||||
|
||||
return conn, func() { _ = conn.Close() }, nil
|
||||
}
|
||||
|
||||
// Run executes a command on the remote host and returns the combined stdout and stderr.
|
||||
//
|
||||
// command MUST be a hardcoded shell literal or constructed entirely from
|
||||
// internal, service-controlled values — never from user-supplied HTTP input.
|
||||
func (c *Client) Run(command string) (string, error) {
|
||||
config := c.getConfig()
|
||||
|
||||
client, err := ssh.Dial("tcp", c.Host+":22", config)
|
||||
conn, closeConn, err := c.dial()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to dial: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
defer func() { _ = client.Close() }()
|
||||
defer closeConn()
|
||||
|
||||
session, err := client.NewSession()
|
||||
session, err := conn.NewSession()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
@@ -133,16 +192,14 @@ func (c *Client) ReadDir(remotePath string) (map[string][]byte, error) {
|
||||
|
||||
// UploadContent uploads the given content to a file on the remote host using stdin piping.
|
||||
func (c *Client) UploadContent(content []byte, remotePath string) error {
|
||||
config := c.getConfig()
|
||||
|
||||
client, err := ssh.Dial("tcp", c.Host+":22", config)
|
||||
conn, closeConn, err := c.dial()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to dial: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = client.Close() }()
|
||||
defer closeConn()
|
||||
|
||||
session, err := client.NewSession()
|
||||
session, err := conn.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
|
||||
@@ -44,3 +44,33 @@ func TestRun_DialFailure(t *testing.T) {
|
||||
t.Errorf("Expected 'failed to dial' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClose_NoOpWithoutConnect(t *testing.T) {
|
||||
client := NewClient("127.0.0.1")
|
||||
|
||||
if err := client.Close(); err != nil {
|
||||
t.Errorf("Close on a never-connected client should be a no-op, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnect_DialFailureLeavesConnNil(t *testing.T) {
|
||||
client := NewClient("127.0.0.1:0")
|
||||
|
||||
err := client.Connect()
|
||||
if err == nil {
|
||||
t.Fatal("Expected dial failure, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "failed to dial") {
|
||||
t.Errorf("Expected 'failed to dial' error, got: %v", err)
|
||||
}
|
||||
|
||||
if client.conn != nil {
|
||||
t.Error("Connect should leave conn nil after a dial failure, so Run/UploadContent still fall back to their own one-off dial")
|
||||
}
|
||||
|
||||
// Close after a failed Connect should still be a harmless no-op.
|
||||
if err := client.Close(); err != nil {
|
||||
t.Errorf("Close after a failed Connect should be a no-op, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,71 @@ After the installation check if you can access AfterTouch from your local device
|
||||
|
||||
### If `http://<IP_ADDRESS_OF_SPEAKER>:8000` fails: SSH port forwarding
|
||||
|
||||
Some firmware images only bind the AfterTouch HTTP port to loopback (see issue #196). The workaround is an SSH tunnel — your machine talks to its own local `:8000`, the SSH connection forwards to the speaker's `:8000` on loopback.
|
||||
On some device models AfterTouch's port is reachable from other machines on
|
||||
your LAN out of the box. On others (see issue #196) it isn't, and (unlike
|
||||
the phrasing this README used to have) that's not AfterTouch or its
|
||||
firewall configuration choosing to bind loopback-only. AfterTouch itself
|
||||
binds `0.0.0.0` (all interfaces) correctly, confirmed by inspecting the
|
||||
running device directly, and there's no firewall rule (`iptables`,
|
||||
`nftables`, or otherwise) blocking it either.
|
||||
|
||||
**Current knowledge (2026-08-16), confirmed on real hardware via a
|
||||
decrypted firmware backup plus simultaneous packet captures on both the
|
||||
speaker and a client machine:** some SoundTouch models built around a
|
||||
"combo" WiFi/Bluetooth co-processor (used for AirPlay) route LAN traffic
|
||||
through that co-processor before it reaches the main application
|
||||
processor where AfterTouch actually runs. That co-processor only relays a
|
||||
fixed set of the device's own original service ports (the same ones the
|
||||
stock SoundTouch app and companion services always used), a list that,
|
||||
as far as we can tell, is compiled into the co-processor's own firmware.
|
||||
AfterTouch's ports were never part of that original design, so they never
|
||||
got included. This isn't a bug in AfterTouch, a router/firewall setting,
|
||||
or WiFi client isolation; all three were separately ruled out.
|
||||
|
||||
**The installer works around this automatically.** On an affected speaker
|
||||
it redirects one of the ports the co-processor *does* relay to AfterTouch,
|
||||
so the UI is reachable from the LAN without any tunnel:
|
||||
|
||||
```
|
||||
http://<IP_ADDRESS_OF_SPEAKER>:17008
|
||||
```
|
||||
|
||||
Port `17008` is Bose's software-update listener; that cloud service no
|
||||
longer exists, so taking over its inbound traffic costs nothing. Only
|
||||
traffic from other machines is affected; anything running on the speaker
|
||||
still reaches AfterTouch on `:8000` as before. Change or disable this with
|
||||
`AFTERTOUCH_LAN_PORT` (`auto` / `none` / a port number) in
|
||||
`/opt/aftertouch/aftertouch.conf`, or pass it at install time:
|
||||
|
||||
```bash
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | AFTERTOUCH_LAN_PORT=none sh
|
||||
```
|
||||
|
||||
**`aftertouch.conf` isn't limited to `AFTERTOUCH_LAN_PORT`.** The init
|
||||
script exports every assignment in this file into the daemon's own
|
||||
environment, so any env var `soundtouch-service` reads (see the
|
||||
[configuration table](../../docs/content/docs/guides/SOUNDTOUCH-SERVICE.md#configuration-options))
|
||||
can be set the same way — for example, to change the admin credentials:
|
||||
|
||||
```
|
||||
MGMT_USERNAME=admin
|
||||
MGMT_PASSWORD=change-me
|
||||
```
|
||||
|
||||
Edit `/opt/aftertouch/aftertouch.conf` over SSH, then
|
||||
`/etc/init.d/aftertouch restart` to apply. `DEPLOYMENT_MODE=on-device` is
|
||||
already set by the init script itself — it never needs to be added here.
|
||||
The auto-export behavior described here needs a build including the fix
|
||||
for issue #546; older installs (before `aftertouch.conf` even existed, or
|
||||
between then and that fix) need to reinstall/update first.
|
||||
|
||||
Which models need this, and how to report one that isn't listed yet, is
|
||||
tracked in
|
||||
[MODEL-SUPPORT-MATRIX.md](../../docs/content/docs/reference/MODEL-SUPPORT-MATRIX.md).
|
||||
The SSH tunnel below still works, and remains the better route for
|
||||
**linking music-service accounts**: Spotify only accepts `https://` or
|
||||
loopback OAuth redirect URIs, so `http://localhost:8000` through a tunnel
|
||||
succeeds where a plain LAN address is rejected.
|
||||
|
||||
**Open a fresh terminal on your own machine** (Linux/macOS/Windows — NOT another shell inside the speaker's SSH session — see issue #250 for the trap that catches everyone here) and run:
|
||||
|
||||
@@ -94,15 +158,17 @@ Run the installer again with the version you want to install. The script backs u
|
||||
**Install (or upgrade to) a specific version** — three equivalent ways:
|
||||
|
||||
```bash
|
||||
# 1. Environment variable (works when piping into sh)
|
||||
VERSION=0.111.3 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
# 1. Environment variable — goes on `sh`, not `curl`: in a pipe, each
|
||||
# command is a separate process, so `VERSION=X curl ... | sh` silently
|
||||
# does NOT set it for `sh` (the one that actually reads $VERSION).
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | VERSION=0.123.0 sh
|
||||
|
||||
# 2. Command-line flag (pass args after `sh -s --`)
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.111.3
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.123.0
|
||||
|
||||
# 3. Download first, then run with a flag
|
||||
curl -sSLo install.sh https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh
|
||||
sh install.sh --version 0.111.3
|
||||
sh install.sh --version 0.123.0
|
||||
```
|
||||
|
||||
Running **without** a version override installs the latest release: the script
|
||||
|
||||
@@ -15,6 +15,7 @@ DESC="Bose AfterTouch service"
|
||||
DAEMON="/opt/aftertouch/aftertouch-service"
|
||||
PIDFILE="/var/run/$NAME.pid"
|
||||
DATADIR="/opt/aftertouch/data"
|
||||
CONFFILE="/opt/aftertouch/aftertouch.conf"
|
||||
SCRIPTNAME="/etc/init.d/$NAME"
|
||||
USER="root"
|
||||
LOG_TAG="aftertouch"
|
||||
@@ -24,6 +25,37 @@ LOG_TAG="aftertouch"
|
||||
export PATH="/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin"
|
||||
|
||||
|
||||
# Optional settings written by install.sh (AFTERTOUCH_LAN_PORT, SERVICE_PORT),
|
||||
# or added by hand for anything the daemon reads from its environment
|
||||
# (SERVER_URL, MGMT_USERNAME, MGMT_PASSWORD, DEPLOYMENT_MODE, ...). `set -a`
|
||||
# auto-exports every assignment while the file is sourced, so any such
|
||||
# variable actually reaches the daemon -- it's forked from this same shell's
|
||||
# environment further down via `--startas "/bin/sh" -- -c "... \"$DAEMON\" ..."`.
|
||||
# Sourced before the defaults below so it can override either.
|
||||
if [ -r "$CONFFILE" ]; then
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
. "$CONFFILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Port the daemon binds locally. Kept in one variable because it appears in
|
||||
# the daemon arguments, the readiness poll and `status` -- three places that
|
||||
# used to hardcode 8000 independently, so changing one silently broke the
|
||||
# other two.
|
||||
SERVICE_PORT="${SERVICE_PORT:-8000}"
|
||||
|
||||
# LAN entry port: a port number, "auto" (default), or "none".
|
||||
LAN_PORT_MODE="${AFTERTOUCH_LAN_PORT:-auto}"
|
||||
|
||||
# This script only ever runs on the speaker itself, so the deployment mode is
|
||||
# not a guess -- default it here (overridable via aftertouch.conf, though that
|
||||
# should never be needed). Exported so soundtouch-service picks it up via
|
||||
# DEPLOYMENT_MODE without needing a --deployment-mode flag threaded through
|
||||
# the daemon invocation below.
|
||||
export DEPLOYMENT_MODE="${DEPLOYMENT_MODE:-on-device}"
|
||||
|
||||
|
||||
# Sanity check executable
|
||||
test -x "$DAEMON" || {
|
||||
echo "ERROR: Cannot execute $DAEMON (check path and permissions)." >&2
|
||||
@@ -31,6 +63,101 @@ test -x "$DAEMON" || {
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LAN entry-port redirect
|
||||
#
|
||||
# On chassis built around a BCO ("SMSC") Wi-Fi/Bluetooth co-processor,
|
||||
# inbound LAN traffic only reaches this Linux SoC for a fixed set of Bose's
|
||||
# own service ports, which appears to be compiled into the co-processor's
|
||||
# firmware. AfterTouch's :8000 is not on that list, so a LAN client's SYN
|
||||
# never arrives here at all -- confirmed on an ST20 (`spotty`), where
|
||||
# `tcpdump -i eth0` on the speaker saw zero packets for :8000 while Bose's
|
||||
# own :8090/:8091/:17000 answered normally from the same client. The usual
|
||||
# suspects were all ruled out: the service does bind 0.0.0.0 correctly, the
|
||||
# speaker's iptables is empty, and SSH over the same path works.
|
||||
#
|
||||
# Workaround: NAT one of the relayed Bose ports to ours. The default, 17008,
|
||||
# is Bose's SoftwareUpdate listener -- its cloud is gone, so taking over its
|
||||
# inbound traffic costs nothing real. Only external traffic is matched
|
||||
# (`! -i lo`), so anything running on the speaker still reaches both the real
|
||||
# service on loopback and AfterTouch on :8000 as before.
|
||||
#
|
||||
# Credit: the STR / SoundTouch Reborn project (github.com/JRpersonal/streborn)
|
||||
# documented and shipped this REDIRECT technique first, using the same entry
|
||||
# port for the same reason.
|
||||
#
|
||||
# Which models need this is tracked in
|
||||
# docs/content/docs/reference/MODEL-SUPPORT-MATRIX.md.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Resolve LAN_PORT_MODE into $LAN_PORT. Returns non-zero when no redirect
|
||||
# should be installed.
|
||||
lan_redirect_port() {
|
||||
case "$LAN_PORT_MODE" in
|
||||
none|off|disabled|0)
|
||||
return 1
|
||||
;;
|
||||
auto|"")
|
||||
# Only auto-enable where direct LAN access is known not to work.
|
||||
# has-bco is Bose's own helper: [ "$(cat /proc/module_type)" = scm ]
|
||||
has-bco >/dev/null 2>&1 || return 1
|
||||
LAN_PORT=17008
|
||||
;;
|
||||
*[!0-9]*)
|
||||
echo "WARNING: ignoring AFTERTOUCH_LAN_PORT='$LAN_PORT_MODE'; expected a port number, 'auto' or 'none'." >&2
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
LAN_PORT="$LAN_PORT_MODE"
|
||||
;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
# Remove every PREROUTING rule pointing at our service port, whatever entry
|
||||
# port it used, so changing AFTERTOUCH_LAN_PORT cannot orphan the old rule.
|
||||
lan_redirect_purge() {
|
||||
iptables -t nat -S PREROUTING 2>/dev/null \
|
||||
| grep -- "--to-ports $SERVICE_PORT" \
|
||||
| sed 's/^-A /-D /' \
|
||||
| while read -r rule; do
|
||||
# shellcheck disable=SC2086
|
||||
iptables -t nat $rule 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
|
||||
lan_redirect_apply() {
|
||||
lan_redirect_port || return 0
|
||||
|
||||
if ! iptables -t nat -L PREROUTING -n >/dev/null 2>&1; then
|
||||
echo "WARNING: this kernel has no iptables nat table; :$LAN_PORT was not" >&2
|
||||
echo " redirected. Reach AfterTouch over an SSH tunnel instead." >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
lan_redirect_purge
|
||||
|
||||
# Safety net for a kernel whose iptables lacks -S (purge would no-op):
|
||||
# without this, every restart would stack another duplicate rule.
|
||||
if iptables -t nat -C PREROUTING ! -i lo -p tcp --dport "$LAN_PORT" \
|
||||
-j REDIRECT --to-ports "$SERVICE_PORT" 2>/dev/null; then
|
||||
echo "LAN access already active on port $LAN_PORT."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if iptables -t nat -I PREROUTING 1 ! -i lo -p tcp --dport "$LAN_PORT" \
|
||||
-j REDIRECT --to-ports "$SERVICE_PORT" 2>/dev/null; then
|
||||
echo "LAN access: port $LAN_PORT now reaches AfterTouch on :$SERVICE_PORT."
|
||||
else
|
||||
echo "WARNING: could not install the :$LAN_PORT -> :$SERVICE_PORT redirect." >&2
|
||||
fi
|
||||
}
|
||||
|
||||
lan_redirect_remove() {
|
||||
lan_redirect_purge
|
||||
}
|
||||
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
echo "Starting $DESC..."
|
||||
@@ -42,37 +169,82 @@ case "$1" in
|
||||
|
||||
mkdir -p "$DATADIR"
|
||||
|
||||
# Pipe stdout + stderr through `logger -t $LOG_TAG` so the
|
||||
# Route stdout + stderr through `logger -t $LOG_TAG` so the
|
||||
# daemon's output lands in busybox syslog (bounded ring buffer,
|
||||
# never grows on disk). Users diagnose with:
|
||||
#
|
||||
# logread | grep aftertouch | tail -20
|
||||
# logread -f | grep aftertouch # live tail
|
||||
#
|
||||
# `exec` on the daemon replaces /bin/sh so --make-pidfile records
|
||||
# the daemon's own PID (not the shell wrapper). The `logger`
|
||||
# process sits on the read end of the pipe and exits cleanly
|
||||
# when the daemon dies and closes its end.
|
||||
# This used to be a `--startas "/bin/sh" -- -c "exec $DAEMON | logger"`
|
||||
# pipeline, on the theory that `exec` replaces /bin/sh so --make-pidfile
|
||||
# records the daemon's own PID. That's wrong for a *piped* command:
|
||||
# POSIX requires each side of a pipe to run in its own forked process,
|
||||
# so the top-level /bin/sh forks two children (one execs into the
|
||||
# daemon, one becomes logger) and stays alive itself, blocked in
|
||||
# wait() -- --make-pidfile recorded *that* wrapper's PID, not the
|
||||
# daemon's. `stop` then killed the wrapper, which doesn't forward
|
||||
# SIGTERM to its children, orphaning the real daemon (reparented to
|
||||
# init) to keep running -- and keep holding :8000 -- forever, silently
|
||||
# surviving every later stop/start/restart.
|
||||
#
|
||||
# A first fix attempt dropped the wrapper shell entirely in favor of
|
||||
# `--exec "$DAEMON"` directly, with a plain shell-level `>FIFO`
|
||||
# redirection on the start-stop-daemon invocation. That broke logging
|
||||
# instead: this busybox's `--background` resets the backgrounded
|
||||
# child's own stdio, ignoring the outer redirection, so the daemon's
|
||||
# output never reached the FIFO -- confirmed on hardware (`logger`
|
||||
# exited immediately with nothing to read, `logread` showed nothing
|
||||
# new).
|
||||
#
|
||||
# This version keeps a wrapper shell -- its *own* FIFO redirection,
|
||||
# set up by its own script logic rather than inherited from outside,
|
||||
# isn't affected by whatever --background did to its stdio -- but has
|
||||
# the wrapper record the daemon's real PID itself instead of trusting
|
||||
# --make-pidfile. $! after a single, non-piped backgrounded command is
|
||||
# portably that command's own PID; --make-pidfile can only ever see
|
||||
# whatever process start-stop-daemon directly forked (the wrapper),
|
||||
# never a PID from inside it.
|
||||
LOGFIFO="/tmp/$NAME.fifo"
|
||||
rm -f "$LOGFIFO"
|
||||
mkfifo "$LOGFIFO"
|
||||
|
||||
# --pidfile (without --make-pidfile, since the wrapper writes it itself
|
||||
# once it knows the daemon's real PID) makes start-stop-daemon's own
|
||||
# "already running?" check keyed on *our* pidfile, not on "/bin/sh"
|
||||
# identity. Without this, --startas "/bin/sh" is itself the match
|
||||
# criterion -- and since the wrapper stays alive for the daemon's whole
|
||||
# lifetime (blocked in its own `wait`), and `stop` only confirms the
|
||||
# *daemon* PID died (not that the wrapper has finished tearing down),
|
||||
# a `restart` firing `start` right after `stop` can catch the previous
|
||||
# wrapper still mid-teardown. start-stop-daemon then silently refuses
|
||||
# ("/bin/sh is already running", swallowed by --quiet) while the
|
||||
# script burns its full 120s timeout waiting for a daemon that was
|
||||
# never launched. Confirmed on hardware: a bare
|
||||
# `start-stop-daemon --startas "/bin/sh" -- -c "echo hi"` was refused
|
||||
# with exactly that message while a prior wrapper was still alive.
|
||||
start-stop-daemon --start \
|
||||
--quiet \
|
||||
--pidfile "$PIDFILE" \
|
||||
--background \
|
||||
--make-pidfile \
|
||||
--chuid "$USER" \
|
||||
--startas "/bin/sh" \
|
||||
-- -c "exec \"$DAEMON\" --data-dir '$DATADIR' --record-interactions=false --discovery-interval=60m 2>&1 | logger -t $LOG_TAG"
|
||||
-- -c "logger -t $LOG_TAG <'$LOGFIFO' & \"$DAEMON\" --data-dir '$DATADIR' --port '$SERVICE_PORT' --record-interactions=false --discovery-interval=60m >'$LOGFIFO' 2>&1 & echo \$! >'$PIDFILE'; wait"
|
||||
|
||||
tries=0
|
||||
max_tries=60
|
||||
while [ $tries -lt $max_tries ]; do
|
||||
if curl -fsS http://localhost:8000 >/dev/null 2>&1; then
|
||||
if curl -fsS "http://localhost:$SERVICE_PORT" >/dev/null 2>&1; then
|
||||
# Only once the service actually answers is it worth pointing LAN
|
||||
# traffic at it.
|
||||
lan_redirect_apply
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
tries=$((tries + 1))
|
||||
done
|
||||
|
||||
echo "ERROR: daemon started but http://localhost:8000 never responded within $((max_tries * 2))s." >&2
|
||||
echo "ERROR: daemon started but http://localhost:$SERVICE_PORT never responded within $((max_tries * 2))s." >&2
|
||||
echo " Inspect the daemon's syslog output:" >&2
|
||||
echo " logread | grep $LOG_TAG | tail -20" >&2
|
||||
exit 1
|
||||
@@ -80,6 +252,9 @@ case "$1" in
|
||||
|
||||
stop)
|
||||
echo "Stopping $DESC..."
|
||||
# Drop the LAN redirect first: leaving it in place while nothing listens
|
||||
# would silently blackhole the entry port.
|
||||
lan_redirect_remove
|
||||
if [ -f "$PIDFILE" ]; then
|
||||
PID=$(cat "$PIDFILE")
|
||||
start-stop-daemon --stop \
|
||||
@@ -120,11 +295,19 @@ case "$1" in
|
||||
# (Gustour's ST30: status said running, curl said
|
||||
# connection-refused). Distinguish the two states here so
|
||||
# status isn't a false-positive.
|
||||
if curl -fsS --max-time 3 http://localhost:8000 >/dev/null 2>&1; then
|
||||
echo "$NAME is running (PID $PID, http://localhost:8000 responding)."
|
||||
if curl -fsS --max-time 3 "http://localhost:$SERVICE_PORT" >/dev/null 2>&1; then
|
||||
echo "$NAME is running (PID $PID, http://localhost:$SERVICE_PORT responding)."
|
||||
if lan_redirect_port; then
|
||||
if iptables -t nat -C PREROUTING ! -i lo -p tcp --dport "$LAN_PORT" \
|
||||
-j REDIRECT --to-ports "$SERVICE_PORT" 2>/dev/null; then
|
||||
echo "LAN access: reachable from other machines on port $LAN_PORT."
|
||||
else
|
||||
echo "LAN access: redirect for port $LAN_PORT is NOT installed." >&2
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
else
|
||||
echo "$NAME PID $PID is alive but http://localhost:8000 is not responding." >&2
|
||||
echo "$NAME PID $PID is alive but http://localhost:$SERVICE_PORT is not responding." >&2
|
||||
echo "Recent log:" >&2
|
||||
logread 2>/dev/null | grep "$LOG_TAG" | tail -10 >&2
|
||||
exit 3
|
||||
|
||||
@@ -5,14 +5,16 @@ set -eo pipefail
|
||||
# curl -sSL .../install.sh | sh
|
||||
# resolves and installs the latest release automatically (see below).
|
||||
#
|
||||
# Pin a specific version via environment variable or the --version/-v flag:
|
||||
# VERSION=0.111.3 curl -sSL .../install.sh | sh
|
||||
# curl -sSL .../install.sh | sh -s -- --version 0.111.3
|
||||
# Pin a specific version via environment variable or the --version/-v flag.
|
||||
# The env var goes on `sh`, not `curl`: in a pipe, each command is its own
|
||||
# process, so `VERSION=X curl ... | sh` silently does NOT set it for `sh`.
|
||||
# curl -sSL .../install.sh | VERSION=0.123.0 sh
|
||||
# curl -sSL .../install.sh | sh -s -- --version 0.123.0
|
||||
VERSION=${VERSION:-}
|
||||
|
||||
# Parse optional command-line arguments so the script can be invoked as:
|
||||
# install.sh --version 0.111.3
|
||||
# install.sh -v 0.111.3
|
||||
# install.sh --version 0.123.0
|
||||
# install.sh -v 0.123.0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version|-v)
|
||||
@@ -29,7 +31,7 @@ GH_REPO=${GH_REPO:-gesellix/Bose-SoundTouch}
|
||||
|
||||
# Used only when the latest-release lookup fails (offline / rate-limited /
|
||||
# a curl without -w support).
|
||||
FALLBACK_VERSION=${FALLBACK_VERSION:-0.111.3}
|
||||
FALLBACK_VERSION=${FALLBACK_VERSION:-0.123.0}
|
||||
|
||||
# Resolve the latest release when no explicit version was provided, by
|
||||
# following the stable redirect https://github.com/<repo>/releases/latest
|
||||
@@ -85,6 +87,24 @@ if [ "$INSTALL_DIR" != "/opt/aftertouch" ]; then
|
||||
ln -sf "$INSTALL_DIR" /opt/aftertouch
|
||||
fi
|
||||
|
||||
# Prune any *.backup/*.old/*.new artefacts left behind by an earlier install
|
||||
# attempt, before doing anything else that needs disk space. /mnt/nv is small
|
||||
# (tens of MB), and if a previous run died between creating its backup and
|
||||
# reaching the GC step below (e.g. "no space left on device" during the
|
||||
# download that follows), that backup would otherwise never get cleaned up --
|
||||
# and low free space is exactly what makes the next attempt likely to die the
|
||||
# same way. Pruning up front makes cleanup idempotent regardless of where a
|
||||
# prior run was interrupted.
|
||||
echo "Disk usage before pre-install GC:"; df -h "$INSTALL_DIR"
|
||||
for f in "$INSTALL_DIR/aftertouch-service".*.backup \
|
||||
"$INSTALL_DIR/aftertouch-service".*.old \
|
||||
"$INSTALL_DIR/aftertouch-service.new"; do
|
||||
[ -f "$f" ] || continue
|
||||
rm -f "$f"
|
||||
echo "Removed stale artefact: $f"
|
||||
done
|
||||
echo "Disk usage after pre-install GC:"; df -h "$INSTALL_DIR"
|
||||
|
||||
curl \
|
||||
-sSL \
|
||||
-o "$UPDATE_TMP_DIR/binary" \
|
||||
@@ -110,10 +130,11 @@ mv "$UPDATE_TMP_DIR/binary" "$INSTALL_DIR/aftertouch-service"
|
||||
chmod +x "$INSTALL_DIR/aftertouch-service"
|
||||
|
||||
# Keep only the backup we just created; prune all older *.backup, *.old, and
|
||||
# *.new artefacts left by earlier installs. /mnt/nv is small (tens of MB),
|
||||
# so accumulation quickly causes "no space left on device" during downloads.
|
||||
# *.new artefacts left by earlier installs. This is a second, defensive pass:
|
||||
# it only matters if something wrote a stray artefact between the pre-install
|
||||
# GC above and here (e.g. a concurrent install run).
|
||||
if [ -n "$BACKUP_FILE" ]; then
|
||||
echo "Disk usage before GC:"; df -h "$INSTALL_DIR"
|
||||
echo "Disk usage before post-install GC:"; df -h "$INSTALL_DIR"
|
||||
for f in "$INSTALL_DIR/aftertouch-service".*.backup \
|
||||
"$INSTALL_DIR/aftertouch-service".*.old \
|
||||
"$INSTALL_DIR/aftertouch-service.new"; do
|
||||
@@ -122,7 +143,36 @@ if [ -n "$BACKUP_FILE" ]; then
|
||||
rm -f "$f"
|
||||
echo "Removed stale artefact: $f"
|
||||
done
|
||||
echo "Disk usage after GC:"; df -h "$INSTALL_DIR"
|
||||
echo "Disk usage after post-install GC:"; df -h "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# Settings file sourced by the init script. Written before the service is
|
||||
# (re)started so the very first start already sees it.
|
||||
#
|
||||
# An existing file is left alone on upgrade -- it may carry the operator's own
|
||||
# choices -- unless AFTERTOUCH_LAN_PORT was passed to this script explicitly.
|
||||
CONF_FILE="$INSTALL_DIR/aftertouch.conf"
|
||||
if [ -n "${AFTERTOUCH_LAN_PORT:-}" ] || [ ! -f "$CONF_FILE" ]; then
|
||||
cat > "$CONF_FILE" <<CONFEOF
|
||||
# AfterTouch on-device settings. Sourced by /etc/init.d/aftertouch, which
|
||||
# exports every assignment here into the daemon's own environment -- so any
|
||||
# env var soundtouch-service reads (see docs: guides/SOUNDTOUCH-SERVICE.md,
|
||||
# "Configuration Options") can be set by adding a line below and running
|
||||
# \`/etc/init.d/aftertouch restart\`, e.g.:
|
||||
# MGMT_USERNAME=admin
|
||||
# MGMT_PASSWORD=change-me
|
||||
#
|
||||
# AFTERTOUCH_LAN_PORT: how AfterTouch is reached from other machines.
|
||||
# auto (default) redirect a spare Bose port to AfterTouch, but only on
|
||||
# speakers whose Wi-Fi co-processor refuses to pass :8000 through.
|
||||
# none never redirect; use an SSH tunnel instead.
|
||||
# <port> always redirect this inbound port to AfterTouch.
|
||||
# See docs: reference/MODEL-SUPPORT-MATRIX.md
|
||||
AFTERTOUCH_LAN_PORT=${AFTERTOUCH_LAN_PORT:-auto}
|
||||
CONFEOF
|
||||
echo "Wrote settings to $CONF_FILE (AFTERTOUCH_LAN_PORT=${AFTERTOUCH_LAN_PORT:-auto})"
|
||||
else
|
||||
echo "Keeping existing settings in $CONF_FILE"
|
||||
fi
|
||||
|
||||
echo "Creating init script..."
|
||||
@@ -136,8 +186,18 @@ mv "$UPDATE_TMP_DIR/init-script" /etc/init.d/aftertouch
|
||||
chmod +x /etc/init.d/aftertouch
|
||||
update-rc.d aftertouch defaults
|
||||
|
||||
echo "Installation complete. Running initial startup..."
|
||||
/etc/init.d/aftertouch start
|
||||
echo "Installation complete. (Re)starting the service..."
|
||||
# Use `restart`, not `start`: if AfterTouch is already running (the normal
|
||||
# case for an in-place upgrade or downgrade), `start` calls start-stop-daemon
|
||||
# with a pidfile that still points at a live PID. start-stop-daemon then
|
||||
# refuses to launch a second instance and exits non-zero -- but this script
|
||||
# has no `set -e` here and never checked that exit status, so the old
|
||||
# process kept running untouched while the new binary sat unused on disk.
|
||||
# The post-install curl check below couldn't catch it either, since the old
|
||||
# process kept answering on :8000 throughout. `restart` stops the old
|
||||
# process first (a no-op if nothing was running yet, e.g. on a fresh
|
||||
# install), guaranteeing the newly-installed binary is the one that starts.
|
||||
/etc/init.d/aftertouch restart
|
||||
|
||||
/etc/init.d/aftertouch status
|
||||
|
||||
@@ -150,10 +210,35 @@ echo "Installation complete. Running initial startup..."
|
||||
# daemon's stdout/stderr through `logger -t aftertouch`, so panics
|
||||
# land in busybox syslog and `logread` reads them out.
|
||||
if curl -fsS --max-time 10 http://localhost:8000 >/dev/null 2>&1; then
|
||||
# We are running ON the speaker, so print the address people actually need
|
||||
# rather than a <your-device-ip> placeholder they have to resolve themselves.
|
||||
LAN_IP=$(ip -4 addr show scope global 2>/dev/null \
|
||||
| awk '/inet /{sub(/\/.*/,"",$2); print $2; exit}')
|
||||
[ -n "$LAN_IP" ] || LAN_IP="<your-device-ip>"
|
||||
|
||||
# If the init script installed a LAN entry-port redirect, that port -- not
|
||||
# 8000 -- is the one reachable from other machines.
|
||||
LAN_PORT=$(iptables -t nat -S PREROUTING 2>/dev/null \
|
||||
| grep -- '-j REDIRECT' \
|
||||
| sed -n 's/.*--dport \([0-9][0-9]*\).*--to-ports 8000.*/\1/p' \
|
||||
| head -1)
|
||||
|
||||
echo ""
|
||||
echo "Installation complete. AfterTouch $VERSION is now running on your device."
|
||||
echo "Connect to http://<your-device-ip>:8000 from another machine on the LAN."
|
||||
echo "If the device doesn't expose :8000 directly, port-forward via SSH:"
|
||||
echo " ssh -L 8000:localhost:8000 root@<IP_ADDRESS_OF_SPEAKER>"
|
||||
echo ""
|
||||
if [ -n "$LAN_PORT" ]; then
|
||||
echo " Open http://$LAN_IP:$LAN_PORT from any machine on your network."
|
||||
echo ""
|
||||
echo " (This speaker's Wi-Fi co-processor does not pass port 8000 through to"
|
||||
echo " AfterTouch, so port $LAN_PORT is redirected to it instead. Set"
|
||||
echo " AFTERTOUCH_LAN_PORT in $CONF_FILE to change or disable this.)"
|
||||
else
|
||||
echo " Open http://$LAN_IP:8000 from any machine on your network."
|
||||
fi
|
||||
echo ""
|
||||
echo "If that doesn't load, reach it through an SSH tunnel instead:"
|
||||
echo " ssh -oHostKeyAlgorithms=+ssh-rsa -L 8000:localhost:8000 root@$LAN_IP"
|
||||
echo "then open http://localhost:8000"
|
||||
else
|
||||
echo "WARNING: the init script reports AfterTouch as running, but" >&2
|
||||
echo " http://localhost:8000 isn't responding. The daemon may have" >&2
|
||||
|
||||
@@ -5,6 +5,18 @@
|
||||
set -eu
|
||||
|
||||
/etc/init.d/aftertouch stop || true
|
||||
|
||||
# `stop` normally removes the LAN entry-port redirect. Repeat it directly in
|
||||
# case the init script was already gone or failed, so no rule is left behind
|
||||
# pointing at a service that no longer exists.
|
||||
iptables -t nat -S PREROUTING 2>/dev/null \
|
||||
| grep -- '--to-ports 8000' \
|
||||
| sed 's/^-A /-D /' \
|
||||
| while read -r rule; do
|
||||
# shellcheck disable=SC2086
|
||||
iptables -t nat $rule 2>/dev/null || true
|
||||
done
|
||||
|
||||
rm -f /etc/init.d/aftertouch
|
||||
update-rc.d -f aftertouch remove
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ Both install the **latest release** by default (resolved from GitHub's
|
||||
specific release:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh v0.111.3
|
||||
sudo bash install-player.sh v0.111.3
|
||||
sudo bash install.sh v0.123.0
|
||||
sudo bash install-player.sh v0.123.0
|
||||
```
|
||||
|
||||
## Removal
|
||||
|
||||
@@ -10,7 +10,7 @@ set -euo pipefail
|
||||
# Examples (override defaults via env vars):
|
||||
#
|
||||
# sudo \
|
||||
# VERSION=v0.111.3 \
|
||||
# VERSION=v0.123.0 \
|
||||
# HTTP_PORT=8081 \
|
||||
# bash install-player.sh
|
||||
#
|
||||
@@ -21,7 +21,7 @@ set -euo pipefail
|
||||
# bash install-player.sh
|
||||
#
|
||||
# Or with a version argument to perform an update:
|
||||
# sudo bash install-player.sh v0.111.3
|
||||
# sudo bash install-player.sh v0.123.0
|
||||
#
|
||||
# Notes:
|
||||
# - This script downloads a release binary for your CPU (auto-detects armv7/arm64/amd64).
|
||||
@@ -41,7 +41,7 @@ if [[ -n "$VERSION" && ! "$VERSION" =~ ^v ]]; then
|
||||
fi
|
||||
GH_REPO="${GH_REPO:-gesellix/Bose-SoundTouch}"
|
||||
# Used only when the latest-release lookup fails (offline / rate-limited).
|
||||
FALLBACK_VERSION="${FALLBACK_VERSION:-v0.111.3}"
|
||||
FALLBACK_VERSION="${FALLBACK_VERSION:-v0.123.0}"
|
||||
SERVICE_NAME="${SERVICE_NAME:-soundtouch-player}"
|
||||
BIN_PATH="${BIN_PATH:-/usr/local/bin/soundtouch-player}"
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ set -euo pipefail
|
||||
# Examples (override defaults via env vars):
|
||||
#
|
||||
# sudo \
|
||||
# VERSION=v0.111.3 \
|
||||
# VERSION=v0.123.0 \
|
||||
# HOSTNAME_FQDN=soundtouch.local \
|
||||
# HTTP_PORT=80 \
|
||||
# HTTPS_PORT=443 \
|
||||
@@ -18,7 +18,7 @@ set -euo pipefail
|
||||
# bash install.sh
|
||||
#
|
||||
# Or with a version argument to perform an update:
|
||||
# sudo bash install.sh v0.111.3
|
||||
# sudo bash install.sh v0.123.0
|
||||
#
|
||||
# Notes:
|
||||
# - This script downloads a release binary for your CPU (auto-detects armv7/arm64/amd64).
|
||||
@@ -37,7 +37,7 @@ if [[ -n "$VERSION" && ! "$VERSION" =~ ^v ]]; then
|
||||
fi
|
||||
GH_REPO="${GH_REPO:-gesellix/Bose-SoundTouch}"
|
||||
# Used only when the latest-release lookup fails (offline / rate-limited).
|
||||
FALLBACK_VERSION="${FALLBACK_VERSION:-v0.111.3}"
|
||||
FALLBACK_VERSION="${FALLBACK_VERSION:-v0.123.0}"
|
||||
SERVICE_NAME="${SERVICE_NAME:-soundtouch-service}"
|
||||
BIN_PATH="${BIN_PATH:-/usr/local/bin/soundtouch-service}"
|
||||
|
||||
@@ -122,7 +122,7 @@ detect_arch_asset() {
|
||||
download_url_for() {
|
||||
local asset="$1"
|
||||
# Release asset pattern used by you earlier:
|
||||
# soundtouch-service-v0.111.3-linux-armv7
|
||||
# soundtouch-service-v0.123.0-linux-armv7
|
||||
echo "https://github.com/gesellix/Bose-SoundTouch/releases/download/${VERSION}/soundtouch-service-${VERSION}-${asset}"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user