---
title: "SoundTouch Troubleshooting Guide"
---
**Complete guide to diagnosing and fixing common SoundTouch Go client issues**

This guide helps you quickly identify and resolve problems with the SoundTouch Go client library. Issues are organized by category with step-by-step solutions.

## 🚨 **Quick Diagnostics**

### Test Your Setup
Run these commands to quickly diagnose your setup:

```bash
# 1. Test discovery
go run ./cmd/soundtouch-cli -discover

# 2. Test specific device connection
go run ./cmd/soundtouch-cli -host 192.0.2.100 -info

# 3. Test basic controls
go run ./cmd/soundtouch-cli -host 192.0.2.100 -volume

# 4. Test network connectivity
ping 192.0.2.100
```

---

## 🔍 **Discovery Issues**

### ❌ "No devices found"

**Symptoms:**
```
🔍 Discovering SoundTouch devices...
❌ No devices found on the network
```

**Causes & Solutions:**

#### 1. **Network Configuration**
```bash
# Check if devices are on same network
ip route show default  # Your gateway
arp -a | grep -i bose   # Look for Bose devices
```

**Solution:** Ensure both your computer and SoundTouch are on the same subnet.

#### 2. **Firewall Issues**
```bash
# Check if firewall is blocking UPnP
sudo ufw status                    # Ubuntu
netsh advfirewall show allprofiles # Windows
```

**Solution:** Allow UPnP traffic (port 1900 UDP) or temporarily disable firewall.

#### 3. **Device Not Ready**
- Power cycle your SoundTouch device
- Wait 30 seconds for full boot
- Check device is connected to network (solid white LED)

#### 4. **Discovery Timeout Too Short**
```go
discoverer := discovery.NewDiscoverer(discovery.Config{
    Timeout: 30 * time.Second,  // Increase timeout
})
```

#### 5. **Use Manual IP**
```go
// Bypass discovery entirely
client := client.NewClientFromHost("192.0.2.100")
```

### ❌ "Discovery timeout"

**Symptoms:**
```
🔍 Discovering SoundTouch devices (timeout: 5s)...
❌ Discovery failed: context deadline exceeded
```

**Solutions:**

1. **Increase timeout:**
```go
discoverer := discovery.NewDiscoverer(discovery.Config{
    Timeout: 15 * time.Second,
})
```

2. **Check network performance:**
```bash
# Test network latency
ping -c 4 192.0.2.1

# Check for network congestion
iperf3 -c 192.0.2.1  # If iperf server available
```

3. **Use wired connection if possible**

---

## 🌐 **Connection Issues**

### ❌ Every cloud source shows `status="UNAVAILABLE"` / can't stream anything

**Symptoms:**

- The speaker's `/sources` (or the soundtouch-cli `source availability` output) lists every cloud-backed source — Spotify, TuneIn, Internet Radio, AirPlay, Amazon, Alexa — as `status="UNAVAILABLE"`.
- Often only AUX shows `status="READY"`.
- The speaker can be reached on the LAN (`:8090/info` works) but no Internet streaming source can be selected.

This is a different failure mode from the [`Curl 7` case below](#-speaker-logs-curl-7-http-0-and-aftertouch-sees-no-http-requests): the speaker can reach AfterTouch but doesn't have the account state to authenticate any cloud surface, so every cloud handler 401s itself out.

**Three-step diagnostic checklist** (in order — the cause is almost always one of these):

#### 1. Is `:443` reachable on AfterTouch?

The AfterTouch Settings tab now ships a preflight that flips ✅ / ❌ for whether the speaker can open a TLS handshake to AfterTouch's HTTPS listener. If `:443` is ❌, follow the steps in [HTTPS-SETUP.md → Binding to port 443](HTTPS-SETUP.md#binding-to-port-443).

A failing preflight at this layer typically presents as `Curl 7, http 0` in the speaker's syslog (see the [`Curl 7` entry below](#-speaker-logs-curl-7-http-0-and-aftertouch-sees-no-http-requests) for the focused walkthrough).

#### 2. Does the speaker have a `margeAccountUUID`?

```bash
curl -s http://<speaker-ip>:8090/info | xmllint --xpath '/info/margeAccountUUID/text()' -
```

If the element is empty (or you get no output), the speaker has no account token — every cloud surface that requires authentication will 401 itself out. The Migration tab in AfterTouch detects this and renders:

> **Current: ❌ Not paired (factory-reset or never paired) — set an ID to pair as part of Apply**

The Devices list also shows a `⚠ Not paired — re-pair` badge. To resolve, **open the Migration tab**, pick a previous account ID from the dropdown (or click **Generate**), and click **Apply** — same flow as the [factory-reset recovery](#-presets-flash-then-revert-to-select-a-preset-after-a-factory-reset) section below.

#### 3. What does `logread` say while you trigger a failing source?

SSH into the speaker (see [DEVICE-LOGGING.md](../appendix/DEVICE-LOGGING.md#1-accessing-system-logs-requires-root)) and capture:

```bash
logread -f | grep -v '127.0.0.1:'
```

…while you select a failing source in the SoundTouch app or via `soundtouch-cli`. The lines around the failed attempt usually name the failing host + protocol — TLS handshake error, token fetch 401, missing route, etc. — and that's enough to file an actionable issue.

**Common outcomes:**

- ❌ `:443` → fix HTTPS routing, sources transition to READY on the next refresh.
- ❌ `margeAccountUUID` empty → run Migration → Apply, sources reappear after `<sourcesUpdated/>` triggers a `/sources` re-sync.
- Everything looks right but sources still UNAVAILABLE → the `logread` snippet is the next signal; open an issue with it attached.

> **Note on the firmware-internal placeholder sources.** The `<sourceItem source="SPOTIFY" sourceAccount="SpotifyConnectUserName" ...>`, `SpotifyAlexaUserName`, `UPNP/UPnPUserName`, `STORED_MUSIC_MEDIA_RENDERER/StoredMusicUserName`, and `QPLAY/QPlay{1,2}UserName` entries that appear in `/sources` even on a broken or unpaired speaker are *firmware-synthesized*. They show up regardless of AfterTouch's source list — their `status="UNAVAILABLE"` does not indicate an AfterTouch problem. Use the three checks above to diagnose the actual cause.

### ❌ On-device install fails with `curl: (60) ... certificate is not yet valid`

**Symptoms:**

- Running the on-device installer over SSH (`curl -sSL .../install.sh | sh`)
  fails immediately, before anything is downloaded:

  ```
  curl: (60) SSL certificate problem: certificate is not yet valid
  ```

- The same error appears for *any* HTTPS fetch from the speaker (GitHub,
  `raw.githubusercontent.com`, …).

**Cause:**

The speaker's clock is set in the past. SoundTouch speakers have no
battery-backed clock and rely on NTP, which is no longer reliable after the Bose
cloud shutdown, so the clock can fall back to a date years ago. TLS validation
then rejects the (recently issued) server certificate as "not yet valid" — its
validity period starts *after* the speaker's notion of "now". This is the same
stuck-clock condition behind several TuneIn / TLS failures (see issue #345).

**Fix:**

Set the speaker's clock to roughly the current time over SSH, then re-run the
installer:

```bash
# On the speaker, over SSH. Replace with the current UTC date/time —
# it only needs to be close enough to fall inside the certificate's validity
# window, not exact.
date -u -s "2026-06-27 12:00:00"
```

Then re-run the on-device install one-liner. Once AfterTouch is installed and
running, its **`speaker_clock` health check** (with a `set_clock` quick-fix)
keeps the speaker's clock corrected, so this is a one-time hurdle to get the
installer through.

### ❌ Speaker logs `Curl 7, http 0` and AfterTouch sees no HTTP requests

**Symptoms:**

In the speaker's log (see [DEVICE-LOGGING.md](../appendix/DEVICE-LOGGING.md#1-accessing-system-logs-requires-root) for the SSH/`logread` setup — the filtered command `logread -f | grep -v '127.0.0.1'` is what you want here):

```
SimpleURLFetcher: retry needed, Curl 7, http 0
```

In the AfterTouch service log: plenty of `[DNS] Intercepted query …` lines but **zero** HTTP requests after each DNS lookup.

**Cause:** speakers connect to Bose hostnames over implicit HTTPS, i.e. port **443**. AfterTouch's built-in HTTPS listener defaults to **8443** because 443 is privileged. The speaker resolves the right IP, dials `:443`, and gets connection refused — which is what `Curl 7` reports.

**Verify:**

```bash
curl -ksS -o /dev/null -w "443=%{http_code}\n"  https://localhost:443/
curl -ksS -o /dev/null -w "8443=%{http_code}\n" https://localhost:8443/
```

Expected when the misconfiguration is present: `443=000` plus a `curl: (7) Failed to connect …` line, `8443=200` (or any 3-digit code).

**Fix:** route `:443` to AfterTouch's HTTPS listener — see [HTTPS-SETUP.md → Binding to port 443](HTTPS-SETUP.md#binding-to-port-443). The AfterTouch settings page shows a ✅ / ❌ indicator for `:443` reachability once the routing is in place.

### ❌ Presets flash then revert to "Select a preset" after a factory reset

**Symptoms:**

- You factory-reset a SoundTouch (Wave / 10 / 20 / 30 / …) that was previously migrated.
- After reconnecting it to Wi-Fi, AfterTouch sees the speaker again, but pressing a preset on the device or in the app makes the display briefly show the preset name and then revert to *"Select a preset or explore music in the SoundTouch App"*.
- Spotify presets show the same revert unless Spotify Connect is started from the mobile app first.
- The speaker's `/sources` is missing TUNEIN / LOCAL_INTERNET_RADIO / DEEZER / your linked Spotify account — only AUX, BLUETOOTH, AIRPLAY, the SpotifyConnectUserName placeholder, NOTIFICATION, and QPLAY appear.

**Cause:**

A factory reset wipes `/mnt/nv/BoseApp-Persistence/1/Marge.xml` — the file that carries the speaker's auth token for the AfterTouch (or Bose) cloud service. The migrated URL configuration is preserved (it lives in `envswitch`), so the speaker keeps talking to AfterTouch, but with no token it can't authenticate for preset playback. Separately, the device's `/sources` cache is reduced until it receives a `<sourcesUpdated/>` notification.

**Fix:**

1. **Re-open the Migration tab** in the AfterTouch UI. The wizard reads `/info`, sees `margeAccountUUID` is empty, and renders:

   > **Current: ❌ Not paired (factory-reset or never paired) — set an ID to pair as part of Apply**

   The devices list now also shows a `⚠ Not paired — re-pair` badge next to such speakers, so you don't have to remember to open the Migration tab cold.

2. **Pick the previously-used account ID** from the "pick from datastore" dropdown (if AfterTouch remembers it), or click **Generate** for a fresh one.

3. **Click Apply.** The wizard runs `pair-account` along with the rest, recreating `Marge.xml` on the device with the chosen ID.

4. **Click Data Sync** (Tab 3). AfterTouch persists the speaker's presets/recents/sources and posts a `<sourcesUpdated/>` notification to the device — the missing TUNEIN / LOCAL_INTERNET_RADIO / DEEZER / linked Spotify entries reappear in `/sources` automatically.

5. Press a preset. It should play normally.

If presets still won't play after step 5, capture `logread -f | grep -v '127.0.0.1:'` on the speaker (see [DEVICE-LOGGING.md](../appendix/DEVICE-LOGGING.md#1-accessing-system-logs-requires-root)) while pressing the preset and file an issue with the snippet — the lines around the failed playback name the deeper cause.

### ❌ "Connection refused"

**Symptoms:**
```go
Failed to connect: dial tcp 192.0.2.100:8090: connection refused
```

**Diagnostic Steps:**

#### 1. **Verify IP and Port**
```bash
# Test if port 8090 is open
telnet 192.0.2.100 8090
# OR
nc -zv 192.0.2.100 8090

# Scan for open ports
nmap -p 8080-8100 192.0.2.100
```

#### 2. **Check Device Status**
- Device LED should be solid white (connected)
- Blinking white = connecting
- Red = error state

#### 3. **Router/Network Issues**
```bash
# Check routing
traceroute 192.0.2.100

# Test basic connectivity
ping -c 4 192.0.2.100
```

### ❌ "Timeout" / "Context deadline exceeded"

**Symptoms:**
```go
Failed to get device info: context deadline exceeded
```

**Solutions:**

#### 1. **Increase Client Timeout**
```go
config := client.ClientConfig{
    Host:    "192.0.2.100",
    Port:    8090,
    Timeout: 30 * time.Second,  // Increase from default 10s
}
```

#### 2. **Check Network Latency**
```bash
# Test response time
ping -c 10 192.0.2.100

# Should be < 100ms typically
```

#### 3. **Device Performance Issues**
- Device may be overloaded
- Try power cycling the device
- Check for firmware updates via Bose app

### ❌ "No such host"

**Symptoms:**
```go
Failed to connect: dial tcp: lookup soundtouch.local: no such host
```

**Solutions:**

1. **Use IP instead of hostname:**
```go
client := client.NewClientFromHost("192.0.2.100")  // Not "soundtouch.local"
```

2. **Fix DNS/mDNS:**
```bash
# Test hostname resolution
nslookup soundtouch.local
dig soundtouch.local

# Install mDNS tools if needed (Linux)
sudo apt-get install avahi-utils
avahi-resolve -n soundtouch.local
```

---

### ⚠️ Health tab: "HTTPS endpoint TLS configuration" warns about the wrong port / not reachable {#https-endpoint-tls-config}

**Symptoms:**

- The Health tab's **HTTPS endpoint TLS configuration** check shows a warning like
  *"Configured HTTPS URL … uses port 443, but the service is listening on port 8443"*,
  or *"Configured HTTPS endpoint … isn't reachable from inside the service."*
- You run AfterTouch on non-default ports (for example HTTP `8080`, HTTPS `8443`).

**Cause:**

AfterTouch advertises an HTTPS URL (used for the DNS-based redirect, Spotify/Amazon
login, and certificate trust) separately from the HTTP one. If that URL's port
doesn't match the port the HTTPS listener is actually bound to, the check dials the
wrong place. This most often happened when the HTTPS URL had been set without a port
(so it defaulted to `443`) while the listener was on `8443`.

**Fix:**

- Open **Settings → Service URLs**. The **HTTPS URL** line shows the effective value.
  By default it now *derives* from the Target Domain (same host, on the HTTPS port),
  so simply saving a correct Target Domain fixes it. Expand the ⓘ next to **HTTPS URL**
  to set an **override** only if a reverse proxy serves HTTPS on a different host/port.
- Equivalent CLI/env: set `--https-server-url` / `HTTPS_SERVER_URL` to include the
  right port, e.g. `https://<host>:8443`, then restart.

**Not always a problem:** if a reverse proxy intentionally terminates TLS on one port
(e.g. `443`) and forwards to AfterTouch on another (e.g. `8443`), the warning is
expected and can be ignored — the check can't see your proxy from inside the service.

---

## 🎵 **Playback Control Issues**

### ❌ "Play/Pause not working"

**Symptoms:**
- Commands succeed but no audio change
- Device shows wrong status

**Diagnostic Steps:**

#### 1. **Check Current Status**
```go
nowPlaying, err := client.GetNowPlaying()
if err == nil {
    fmt.Printf("Status: %s, Source: %s\n",
        nowPlaying.PlayStatus, nowPlaying.Source)
}
```

#### 2. **Verify Source Selection**
```go
sources, err := client.GetSources()
if err == nil {
    for _, source := range sources.Sources {
        fmt.Printf("Source: %s, Status: %s\n",
            source.Source, source.Status)
    }
}
```

**Solutions:**

1. **Select active source first:**
```go
client.SelectSpotify()
time.Sleep(2 * time.Second)  // Wait for source change
client.Play()
```

2. **Use key commands instead:**
```go
client.SendKey("PLAY")   // Instead of client.Play()
client.SendKey("PAUSE")  // Instead of client.Pause()
```

3. **Check device isn't in setup mode**

### ❌ "Source selection fails"

**Symptoms:**
```go
Failed to select source: API request failed with status 500
```

**Solutions:**

1. **Check source availability:**
```go
sources, _ := client.GetSources()
for _, source := range sources.Sources {
    if source.Source == "SPOTIFY" && source.Status == "READY" {
        // Source is available
        client.SelectSource("SPOTIFY", source.SourceAccount)
    }
}
```

2. **Account-specific sources:**
```go
// For streaming services, include account
client.SelectSource("SPOTIFY", "your_account_id")
```

3. **Use convenience methods:**
```go
client.SelectSpotify()    // Handles account automatically
client.SelectBluetooth()
client.SelectAux()
```

---

## 🎛️ **Lifestyle / Console Device Behavior** {#lifestyle-console-devices}

### ❌ "Console-style device (Lifestyle, CineMate) plays the first test station but every later one reports INVALID_SOURCE"

On a Bose Lifestyle or CineMate console, the SoundTouch module is one input
among several (TV, AUX, Bluetooth, ...). As already established in #160,
the console's active input cannot be switched from the SoundTouch side —
there is no API call that forces it back onto SoundTouch.

**Symptoms:**
- `/now_playing` reports `source="LOCAL"` with an empty `ContentItem`:
  ```xml
  <nowPlaying deviceID="..." source="LOCAL">
    <ContentItem source="LOCAL" isPresetable="true" />
  </nowPlaying>
  ```
- `LOCAL` does not appear in `/sources` at all.
- `POST /select` and `POST /key` (e.g. `PRESET_1`) are accepted
  (`<status>/select</status>`) but have no observable effect.

This means the console is sitting on its own (non-SoundTouch) input, not
that the content/station itself is invalid. The input has to be selected
on the console's own remote or front panel; there is no way to do it via
the SoundTouch API.

**The trap:** `POST /key POWER` does not behave like it does on a plain
speaker. On a speaker, `POWER` is a harmless way to stop playback between
test runs. On a console, it puts the whole unit into standby — and on
waking, the console returns to **its own** input, not back to SoundTouch.
A test loop that stops playback with `POWER` between trials silently
switches the device off SoundTouch after the *first* trial, so every
station from the second one onward reports `INVALID_SOURCE` — including
stations that would otherwise play perfectly fine. This is easy to
misread as a per-station problem (e.g. "this console can't handle TLS/
https streams") when it is actually a test-methodology artifact: whichever
station happens to run first in the loop is the only one actually tested
against SoundTouch input.

**Solutions:**

1. Before testing anything, select the SoundTouch input on the console
   itself (remote or front panel), not via the API.
2. Do not use `POST /key POWER` to stop playback between trials on these
   devices. If you need to interrupt playback, use a different key
   (e.g. `PAUSE`/`STOP`) or simply move directly to selecting the next
   station.
3. If `/now_playing` shows `source="LOCAL"` with `LOCAL` absent from
   `/sources`, treat that as "console is on a different input" — re-select
   SoundTouch on the console and retest before concluding anything about
   the station or migration itself.

See #597 for the original report, including a packet capture confirming a
station that appeared to fail actually completed a full TLS handshake and
streamed normally once the console was back on the SoundTouch input.

---

## 🎶 **Music Service & Preset Issues**

### ❌ Spotify preset fails with "Current content cannot be saved as preset"

**Symptoms:**

You push playback to the speaker via Spotify Connect from the Spotify mobile/desktop app. Audio plays fine. You try to store it as a preset and the CLI reports:

```
$ soundtouch-cli preset store-current --slot 2
Storing current content as preset 2 from 192.168.x.y:8090...
✗ Current content cannot be saved as preset
  Content: <track name>
  Source: SPOTIFY
2026/05/16 09:13:10 current content cannot be preset
```

…and `soundtouch-cli play now` shows `Source Account: SpotifyConnectUserName`.

**Cause:**

The speaker firmware marks Spotify-Connect-pushed content as **non-presetable** at the NowPlaying layer:

```xml
<ContentItem source="SPOTIFY" type="DO_NOT_RESUME" ...
             sourceAccount="SpotifyConnectUserName" isPresetable="false">
```

That `isPresetable="false"` means the firmware can't independently re-fetch the stream later — it only knows about the session token your phone pushed via the Spotify Connect protocol, which is ephemeral. The speaker refuses the preset *locally*, before any storePreset request reaches AfterTouch's marge.

**Why an OAuth-linked Spotify account changes the answer:**

When AfterTouch has a Spotify OAuth account linked (see [MUSIC-SERVICES.md](MUSIC-SERVICES.md)), the speaker has a *persistent* Spotify source it can use to resolve the content URI later — typically an album/playlist container. With that source available, the firmware rewrites the content item from `DO_NOT_RESUME` to `tracklisturl` at save time, flips `isPresetable` to `true`, and the preset goes through. The recall path then routes through AfterTouch's `/oauth/.../cs3` token broker, which returns a Spotify access token for your linked account.

**Fix:**

1. Set up Spotify OAuth in AfterTouch following [MUSIC-SERVICES.md](MUSIC-SERVICES.md). The high-level model (Spotify Connect vs the OAuth-intercept path, the `streamingoauth.bose.com` DNS rewrite, the token lifecycle) is in [spotify-overview.md](../concepts/spotify-overview.md).
2. Make sure you're on **v0.84.0 or later** — earlier versions had a custom-OAuth-client bug that caused playback to hang at "Buffering".
3. Re-prime the speaker (Migration tab → **Prime Spotify**, or wait for the watchdog), then retry the preset save with Connect-pushed playback.

**What this won't fix:**

A Connect-only setup with no OAuth account linked in AfterTouch — that's a firmware-level constraint we can't route around from the server side. The speaker simply doesn't have credentials it can use to replay the content later, so it refuses to preset.

### ❌ TuneIn (or Internet Radio) missing from `/sources` after a factory reset

**Symptoms:**

- The speaker is happily migrated and reachable; most cloud sources work.
- `curl http://<speaker-ip>:8090/sources` lists AUX, Bluetooth, Spotify Connect placeholders, etc. — but **no `TUNEIN` entry**.
- `soundtouch-cli source content --source TUNEIN --type stationurl --location /v1/playback/station/<id> --name '<name>'` fails with `1005` (or playing a TuneIn preset silently does nothing).
- Other devices on the same setup have `TUNEIN` in `/sources` and work fine.

**Cause:**

TuneIn is **not a default source** on a freshly factory-reset SoundTouch. The speaker only adds `TUNEIN` to its `Sources.xml` after the source has been played at least once. Until then, source-selection requests for `TUNEIN` are rejected as invalid.

This is firmware behaviour — independent of AfterTouch — and is why one device can have `TUNEIN` and a sibling device (just reset) can be missing it. The same applies to `LOCAL_INTERNET_RADIO` if the speaker was reset before any LIR content was played.

**Fix:**

Play any TuneIn station once to register the source. Two equivalent paths:

1. **Via the SoundTouch app** — open the app, pick TuneIn, play any station. The source appears in `/sources` after a few seconds.
2. **Via `soundtouch-cli`** on a device that *does* still have TuneIn registered, or by first registering it with a known-working station:

   ```bash
   soundtouch-cli --host <speaker-ip> source content \
     --source TUNEIN --type stationurl \
     --location /v1/playback/station/s166521 \
     --name 'SMOOTH JAZZ'
   ```

   (Station `s166521` is one that works for AfterTouch testing; any valid TuneIn station ID works.)

Once the source plays once, it gets persisted to `/mnt/nv/BoseApp-Persistence/1/Sources.xml` and subsequent TuneIn requests succeed without needing the app.

**For speakers without SSH:**

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:**

- The speaker was migrated **in place** (not factory-reset first) and is reachable; account-bound sources (for example a music-streaming login) work and presets for them play.
- **Every** radio-type source fails: selecting any `LOCAL_INTERNET_RADIO`, `TUNEIN`, or `RADIO_BROWSER` content returns `1005`, including the Health tab's "Play ding" test.
- `curl http://<speaker-ip>:8090/sources` lists no radio source types at all.
- The Health check warns that the speaker is "missing N source type(s) the service advertises".
- The entries are present on disk in **both** the service-side `Sources.xml` **and** the speaker's own `/mnt/nv/BoseApp-Persistence/1/Sources.xml`, yet a reboot and a `sourcesUpdated` notification do not make them activate.

**Cause:**

After some in-place migrations the speaker's **runtime** `bmxRegistryUrl` (and often `statsServerUrl`) are still pointing at the dead Bose cloud (`content.api.bose.io` / `events.api.bosecm.com`), even though the persisted config and `Sources.xml` look correct. Radio sources (TUNEIN, RADIO_BROWSER, LOCAL_INTERNET_RADIO, …) are published through the **BMX registry**, so while `bmxRegistryUrl` points at the dead cloud the speaker can't fetch them and they never mount. On some models a full reboot reconciles all four service URLs from the stored config; on others it does not. (If you hit this, an encrypted diagnostic report taken **before** you reset the speaker is very helpful, and now includes the speaker's on-device `Sources.xml`. See the "Getting More Help" section below.)

**Workaround (preferred — non-destructive):**

Re-run the migration with the **telnet** method, which writes all four service URLs directly onto the speaker's runtime. No factory reset, no DNS, no SSH:

```bash
soundtouch-cli --host <speaker-ip> setup migrate --method telnet --service-url http://<aftertouch-host>:8000
```

Then reboot the speaker (or use "Refresh sources"). Afterwards the Migration tab's cross-check should show `bmxRegistryUrl` / `statsServerUrl` on AfterTouch, and the radio sources activate.

Notes:

- This needs the speaker's telnet diagnostic port (`17000`) to be reachable. Most SoundTouch models expose it; some hardened firmware builds do not, in which case use the factory-reset fallback below.
- It writes AfterTouch's address (`http://<aftertouch-host>:8000`) **straight onto the speaker**, so there is no `bose:8000` hostname for the speaker to resolve. That is why pointing the service at `http://bose:8000` and adding a `bose` entry to your server's `/etc/hosts` does **not** help: the speaker is a separate device and never reads that file. If you prefer to redirect in the network instead of writing on the device, enable AfterTouch's built-in DNS (Settings) and have the speaker use AfterTouch as its resolver — see the FRITZ!Box + AdGuard guide.
- Get `soundtouch-cli` from the [Downloads page](../downloads/_index.md) if you don't already have it.

**Workaround (fallback — factory reset):**

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 — 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.

After this the radio sources activate normally. Note the factory reset rewrites the speaker's `Sources.xml` to defaults, so any **account-bound** source (for example a music-streaming login) has to be re-added afterwards; your presets for it come back once the source is present again.

### ❌ `setup enable-ssh` (or a telnet command) fails right after a power-cycle, but works if you wait

**Symptoms:**

- You power-cycled the speaker — as our own retry guidance suggests after a `setup enable-ssh` timeout — and immediately re-ran the command (or a telnet migration/pairing step).
- You get `telnet dial <ip>:17000: connection refused` or the command otherwise fails as if the port were closed.
- Running the exact same command again a minute or two later works fine, on the same device.

**Cause:**

Confirmed on hardware across five device variants (2026-08-09): different ports on the same speaker become ready at very different times after a cold boot. HTTP `:8090` typically answers first, but the diagnostic telnet shell on `:17000` — and the config subsystem behind it that `getpdo` reads — takes longer: 55–92 seconds observed, median ~70s. "The box answers on one port" is a weaker signal than "the box can answer on the specific port you need." See [TELNET-COMMAND-REFERENCE.md](../analysis/TELNET-COMMAND-REFERENCE.md) for the underlying mechanism.

**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"

**Symptoms:**
- Volume commands succeed but no change
- "Permission denied" errors

**Diagnostic Steps:**

#### 1. **Check Zone Status**
```go
zoneStatus, err := client.GetZoneStatus()
if err == nil {
    fmt.Printf("Zone Status: %s\n", zoneStatus)
}
```

**Solutions:**

1. **Zone Member Issue:**
```go
// Only zone master can control volume
if zoneStatus == "MEMBER" {
    fmt.Println("Device is zone member - only master controls volume")

    // Find and use master device
    zone, _ := client.GetZone()
    // Connect to master device using zone.Master ID
}
```

2. **Use Safe Volume Methods:**
```go
client.SetVolumeSafe(50)     // Clamps to valid range
client.IncreaseVolume(5)     // Incremental control
client.DecreaseVolume(5)
```

3. **Check Current Volume:**
```go
volume, _ := client.GetVolume()
fmt.Printf("Target: %d, Actual: %d, Muted: %t\n",
    volume.TargetVolume, volume.ActualVolume, volume.Muted)
```

### ❌ "Bass/Balance control not supported"

**Symptoms:**
```go
Failed to set bass: API request failed with status 404
```

**Solutions:**

1. **Check device capabilities:**
```go
caps, err := client.GetCapabilities()
if err == nil {
    fmt.Printf("Bass capable: %t\n", caps.BassCapable)
}
```

2. **Use safe methods:**
```go
client.SetBassSafe(-5)       // Won't fail on unsupported devices
client.SetBalanceSafe(10)    // Falls back gracefully
```

3. **Device-specific features:**
- SoundTouch 10: Basic bass only
- SoundTouch 20/30: Full bass and balance
- Soundbar models: Advanced audio controls

---

## 🔔 **Speaker Notification Issues**

### ❌ "speaker beep" command fails with status 400

**Symptoms:**
```bash
$ go run ./cmd/soundtouch-cli --host 192.0.2.10 sp beep
Playing notification beep from 192.0.2.10:8090...
✗ Failed to play notification beep: API request failed with status 400
```

**Cause:**
This was a bug in earlier versions where the Go client incorrectly used POST instead of GET for the `/playNotification` endpoint.

**Solution:**
Update to the latest version. The fix changed the `PlayNotificationBeep()` method to use GET requests:

```go
// Fixed implementation (v2025.02+)
func (c *Client) PlayNotificationBeep() error {
    var status models.StationResponse
    return c.get("/playNotification", &status)
}
```

**Verification:**
Both commands should now work identically:
```bash
# CLI command
go run ./cmd/soundtouch-cli --host 192.0.2.10 sp beep

# Direct curl (for comparison)
curl http://192.0.2.10:8090/playNotification
```

### ❌ "speaker" commands not supported

**Symptoms:**
```
✗ Failed to play notification: endpoint not supported
```

**Causes & Solutions:**

#### 1. **Device Model Compatibility**
- ✅ **Supported**: SoundTouch 10 (ST-10), SoundTouch 20 (ST-20)
- ❌ **Not Supported**: SoundTouch 300 (ST-300), older models

**Solution:** Verify device model with:
```bash
soundtouch-cli --host <device> info
```

#### 2. **Missing App Key (TTS/URL only)**
TTS and URL playback require an app key, but beep does not:
```bash
# Beep - no app key needed
soundtouch-cli --host <device> speaker beep

# TTS - app key required
soundtouch-cli --host <device> speaker tts --text "Hello" --app-key "your-key"
```

### ❌ "Device is busy" during notifications

**Symptoms:**
```
✗ Failed to play notification: device is busy
```

**Solutions:**

#### 1. **Wait for Current Notification to Complete**
Only one notification can play at a time. Wait a few seconds and retry.

#### 2. **Check Current Playback Status**
```go
nowPlaying, _ := client.GetNowPlaying()
fmt.Printf("Current source: %s, status: %s\n",
    nowPlaying.Source, nowPlaying.PlayStatus)
```

### ❌ soundtouch-player TTS fails with `certificate signed by unknown authority`

**Symptoms:**
```
TTS service request failed: Post "https://soundtouch.fritz.box/setup/tts/speak":
tls: failed to verify certificate: x509: certificate signed by unknown authority
```

**Cause:** TTS synthesis and the Bose app key live in `soundtouch-service`,
so `soundtouch-player` proxies the "Speak" action to the service. When the
service is served over HTTPS with its own self-signed certificate (the
default — see `GET /setup/ca.crt`), `soundtouch-player` doesn't trust that CA out
of the box, so the proxied call fails verification.

**Solution:** start `soundtouch-player` with `--service-ca` pointing at the
service's CA certificate (its `<dataDir>/certs/ca.crt`, or the file served at
`/setup/ca.crt`):

```bash
soundtouch-player \
  --service-url https://soundtouch.fritz.box \
  --service-ca /path/to/certs/ca.crt
```

`SERVICE_CA` is the equivalent environment variable. The CA is appended to the
system trust store, so a service URL that uses a publicly trusted certificate
needs no flag.

### ❌ soundtouch-player TTS returns `host ... is not a known device`

**Symptoms:**
```
TTS service returned 400: {"error":"host http://192.0.2.10:8090 is not a known device"}
```

**Cause:** the service only plays TTS on speakers it knows (an SSRF guard:
the target is matched against the service's device datastore, never taken
verbatim from the request).

**Solution:** make sure the target speaker is known to `soundtouch-service`
(discovered or manually added, and migrated to AfterTouch), not only to
`soundtouch-player`'s own discovery. Check with `GET /setup/devices` on the
service. (Recent `soundtouch-player` versions identify the speaker by its device
ID and a bare IP, so this error otherwise indicates the speaker simply isn't
registered with the service.)

---

## 📡 **WebSocket Issues**

### ❌ "WebSocket connection failed"

**Symptoms:**
```go
Failed to connect WebSocket: dial ws://192.0.2.100:8080/: connection refused
```

**Solutions:**

#### 1. **Verify WebSocket Port (8080)**
```bash
# WebSocket uses port 8080, not 8090
nc -zv 192.0.2.100 8080
```

#### 2. **Check Protocol Specification**
```go
// WebSocket client should auto-handle this
wsClient := client.NewWebSocketClient(nil)

// Manual connection (if needed)
url := "ws://192.0.2.100:8080/"
headers := http.Header{}
headers.Set("Sec-WebSocket-Protocol", "gabbo")
```

#### 3. **Connection Conflicts**
- Only one WebSocket connection per device
- Close other apps using SoundTouch
- Restart SoundTouch device if needed

### ❌ "WebSocket disconnects frequently"

**Symptoms:**
- Connection drops every few minutes
- Constant reconnection messages

**Solutions:**

1. **Increase ping interval:**
```go
config := client.DefaultWebSocketConfig()
config.PingInterval = 60 * time.Second    // Increase from 30s
config.PongTimeout = 20 * time.Second     // Increase timeout

wsClient := client.NewWebSocketClient(config)
```

2. **Check network stability:**
```bash
# Test for packet loss
ping -c 100 192.0.2.100 | grep loss
```

3. **Power management issues:**
```bash
# Disable WiFi power saving (Linux)
sudo iwconfig wlan0 power off

# Check Windows power management
powercfg -devicequery wake_armed
```

### ❌ "Events not received"

**Symptoms:**
- WebSocket connects but no events
- Missing volume/playback updates

**Solutions:**

1. **Verify event handlers:**
```go
wsClient.OnVolumeUpdated(func(event *models.VolumeUpdatedEvent) {
    fmt.Printf("Volume event received: %d\n", event.Volume.TargetVolume)
})

// Test by manually changing volume on device
```

2. **Check event parsing:**
```go
wsClient.OnUnknownEvent(func(event *models.WebSocketEvent) {
    fmt.Printf("Unknown event: %+v\n", event)
})
```

3. **Device activity required:**
- Events only sent when device state changes
- Try manual volume/source changes
- Check device isn't in standby

---

## 👥 **Multiroom Issues**

### ❌ "Zone creation fails"

**Symptoms:**
```go
Failed to create zone: API request failed with status 400
```

**Solutions:**

#### 1. **Check Device Compatibility**
```go
// Get device capabilities
caps, _ := client.GetCapabilities()
// Look for multiroom support

// Verify devices are on same network
for _, client := range clients {
    network, _ := client.GetNetworkInfo()
    fmt.Printf("Device IP: %s\n", network.GetConnectedInterface().IPAddress)
}
```

#### 2. **Correct Device IDs**
```go
// Get exact device IDs
info, _ := client.GetDeviceInfo()
masterID := info.DeviceID  // Use this, not MAC address

// Create zone with proper IDs
client.CreateZone(masterID, []string{member1ID, member2ID})
```

#### 3. **Sequential Zone Operations**
```go
// Don't create multiple zones simultaneously
client1.CreateZone(master1, []string{member1})
time.Sleep(2 * time.Second)
client2.CreateZone(master2, []string{member2})
```

### ❌ "Device won't join zone"

**Symptoms:**
- Zone creation succeeds but member doesn't join
- Member device shows as standalone

**Solutions:**

1. **Check device status:**
```go
status, _ := memberClient.GetZoneStatus()
fmt.Printf("Member status: %s\n", status)

if status == "STANDALONE" {
    // Device didn't join - check network/permissions
}
```

2. **Firmware compatibility:**
- Ensure all devices have recent firmware
- Update via Bose SoundTouch app
- Some very old devices don't support multiroom

3. **Network subnet issues:**
```bash
# Verify devices can reach each other
ping -c 4 member_device_ip
```

---

## 🔧 **Development & Debugging**

### Enable Detailed Logging

```go
import "log"

// Enable verbose HTTP logging
log.SetFlags(log.LstdFlags | log.Lshortfile)

// Custom HTTP client with debug
transport := &http.Transport{
    // Add debug transport if needed
}

config := client.ClientConfig{
    Host:    "192.0.2.100",
    Port:    8090,
    Timeout: 10 * time.Second,
}
```

### Debug WebSocket Events

```go
wsClient.OnUnknownEvent(func(event *models.WebSocketEvent) {
    log.Printf("Raw event: %+v", event)
})

// Enable WebSocket debug logging
config := client.DefaultWebSocketConfig()
config.Logger = &client.DefaultLogger{}  // Or custom logger
```

### Network Debugging Tools

```bash
# Capture SoundTouch traffic
sudo tcpdump -i any host 192.0.2.100 and port 8090

# Monitor WebSocket traffic
sudo tcpdump -i any host 192.0.2.100 and port 8080

# HTTP debugging with curl
curl -v http://192.0.2.100:8090/info
curl -v http://192.0.2.100:8090/volume
```

---

## 📊 **Performance Issues**

### High Memory Usage

**Symptoms:**
- Go process memory keeps growing
- Out of memory errors in long-running apps

**Solutions:**

1. **Connection cleanup:**
```go
// Always close WebSocket connections
defer wsClient.Disconnect()

// Use connection pools for multiple devices
pool := NewConnectionPool(10, 5*time.Minute)
defer pool.Close()
```

2. **Goroutine leaks:**
```go
// Use context for cancellation
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// Monitor goroutines
go func() {
    for {
        fmt.Printf("Goroutines: %d\n", runtime.NumGoroutine())
        time.Sleep(10 * time.Second)
    }
}()
```

### Slow Response Times

**Solutions:**

1. **Increase timeouts appropriately:**
```go
config := client.ClientConfig{
    Timeout: 15 * time.Second,  // Reasonable for network ops
}
```

2. **Use connection pooling:**
```go
// Reuse connections instead of creating new ones
pool := NewConnectionPool(5, 5*time.Minute)
client := pool.GetClient(host, port)
```

3. **Concurrent operations:**
```go
// Process multiple devices concurrently
var wg sync.WaitGroup
for _, client := range clients {
    wg.Add(1)
    go func(c *client.Client) {
        defer wg.Done()
        // Process device
    }(client)
}
wg.Wait()
```

---

## 🚨 **Emergency Procedures**

### Device Becomes Unresponsive

1. **Power cycle device:**
   - Unplug for 10 seconds
   - Reconnect and wait 30 seconds for boot

2. **Network reset:**
   - Hold Bluetooth and Volume Down for 10 seconds
   - Device will reset network settings

3. **Factory reset (last resort):**
   - Hold Power for 10 seconds while plugged in
   - Will lose all presets and settings

### Multiple Devices Acting Strange

1. **Check router:**
   - Restart router/access point
   - Check for firmware updates
   - Verify DHCP/IP assignment

2. **Network interference:**
   - Check for 2.4GHz interference
   - Try 5GHz WiFi if available
   - Check for microwave/Bluetooth interference

### App Crashes or Hangs

1. **Graceful shutdown:**
```go
// Always use context for cancellation
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

// Cleanup resources
defer func() {
    if wsClient != nil {
        wsClient.Disconnect()
    }
}()
```

2. **Resource monitoring:**
```go
// Monitor resource usage
go func() {
    var m runtime.MemStats
    for {
        runtime.ReadMemStats(&m)
        log.Printf("Alloc = %d KB, Sys = %d KB", m.Alloc/1024, m.Sys/1024)
        time.Sleep(30 * time.Second)
    }
}()
```

---

## 📋 **Diagnostic Checklist**

Use this checklist to systematically troubleshoot issues:

### Network Connectivity
- [ ] Device power LED is solid white
- [ ] Both devices on same network subnet
- [ ] Firewall allows ports 8090 (HTTP) and 8080 (WebSocket)
- [ ] Can ping device IP address
- [ ] Can telnet to ports 8090 and 8080

### Device Status
- [ ] Device not in setup mode (solid white LED)
- [ ] Recent firmware version (check Bose app)
- [ ] Device responds to Bose app
- [ ] No other apps connected to device

### Code Configuration
- [ ] Correct IP address and ports
- [ ] Reasonable timeouts (10-30 seconds)
- [ ] Proper error handling
- [ ] Resource cleanup (defer statements)

### Multiroom Specific
- [ ] All devices support multiroom
- [ ] Device IDs are correct (from GetDeviceInfo)
- [ ] Devices on same network subnet
- [ ] No existing zone conflicts

---

## 🆔 **Device Identification & Mapping Issues**

### ❌ "File not found" errors with MAC addresses

**Symptoms:**
```
GET /streaming/account/1000001/device/AABBCCDDEEFF/presets
→ 500 Internal Server Error
→ Log: "open .../devices/AABBCCDDEEFF/Presets.xml: no such file or directory"
```

**Cause:** The service uses MAC addresses in API requests but stores files using device serial numbers. A mapping system resolves MAC addresses to serial numbers automatically.

**Quick Solutions:**

1. **Restart the service** (mappings are created at startup):
```bash
sudo systemctl restart soundtouch-service
```

2. **Check device directory structure**:
```bash
# Files should be stored by serial number, not MAC
ls data/accounts/1000001/devices/
# Should show: I6332527703739342000020/ (not AABBCCDDEEFF/)
```

3. **Verify DeviceInfo.xml contains MAC address**:
```bash
cat data/accounts/1000001/devices/*/DeviceInfo.xml | grep macAddress
```

**For detailed diagnosis and solutions**, see: [**MAC Address Mapping Guide**](MAC-ADDRESS-MAPPING.md)

---

## 🌐 **Cross-subnet / VLAN isolation** {#cross-subnet}

### ❌ AfterTouch unreachable when speaker and server are on different subnets

**Symptoms:**

- AfterTouch is running and reachable from your computer, but the speaker cannot
  connect to it after migration.
- `logread | grep aftertouch` on the speaker shows no outgoing requests, or shows
  `Curl 7, http 0` for the AfterTouch host.
- Everything works when speaker and server are on the same `/24` subnet, but fails
  when they are on different VLANs (e.g. IoT VLAN `192.168.20.x` vs server VLAN
  `192.168.10.x`).

**Cause:**

Since a 2018 firmware update, SoundTouch devices apply an iptables policy that
blocks incoming traffic from subnets other than their own. The policy lives in
`/etc/init.d/Firewalls/update_iptables` inside the `block_remote_traffic()`
function.

**Fix A — Add an explicit ACCEPT rule for your AfterTouch subnet (targeted):**

SSH into the speaker and edit the file:

```bash
ssh -oHostKeyAlgorithms=+ssh-rsa root@<speaker-ip>
rw
cd /etc/init.d/Firewalls/
vi update_iptables
```

In `vi`, find `block_remote_traffic()`. Press `i` to enter insert mode. After the
first `done` line in that function, add:

```
echo -A INPUT -i $IFACE -s 192.0.2.0/24 -j ACCEPT
```

Replace `192.0.2.0/24` with the subnet your AfterTouch host is on. Press `Esc`,
type `:wq`, press `Enter`, then reboot:

```bash
reboot
```

To allow **all** subnets, use a wider CIDR range such as `192.168.0.0/16` or
`0.0.0.0/0`.

**Fix B — Comment out the DROP rule (simpler, allows all inbound traffic):**

Instead of adding an ACCEPT rule, find the `DROP` line inside
`block_remote_traffic()` and comment it out by prepending `#`:

```bash
# Before:
echo -A INPUT -i $IFACE ! -s $ADDR/$CIDR -j DROP
# After:
# echo -A INPUT -i $IFACE ! -s $ADDR/$CIDR -j DROP
```

This is less targeted than Fix A but simpler if you run the speaker in an already
firewalled network.

**Related: ST20 Series I also blocks outbound connections to non-standard ports**

Some older firmware images (observed on SoundTouch 20 Series I) also apply an
outbound iptables policy that blocks connections to ports other than 80 and 443.
If AfterTouch is running on a non-standard port (e.g. 8000, 8080) and the speaker
simply never reaches it, this policy may be the cause. Fix A and Fix B apply
equally — inspect `update_iptables` for matching DROP rules on the OUTPUT chain.

*This behaviour was first documented in
[Discussion #354](https://github.com/gesellix/Bose-SoundTouch/discussions/354).*

---

## 🌐 **Hostname Resolution** {#hostname-resolution}

### Why the service resolves the hostname from the device

When you migrate a speaker using the resolv.conf method, the service needs to write a raw IP address into the speaker's network configuration. That IP must be the address the *speaker itself* can reach — which is not necessarily the same address your computer resolves.

In environments with NAT, split-horizon DNS, or Docker/container networking, `soundtouch.local` (or whatever you set as `SERVER_URL`) may resolve to a different IP depending on who is asking. The service therefore resolves the hostname by running `ping -c 1 <hostname>` over SSH on the speaker and extracting the IP from the output. This is the authoritative result: it is exactly what the speaker would use.

If that SSH ping fails, migration is aborted. Writing an unresolvable or incorrectly resolved hostname into `aftertouch.resolv.conf` would silently break the speaker's DNS config and prevent it from reaching the service after reboot.

**The XML migration method is different.** It writes the full URL (e.g. `http://soundtouch.local:8000`) into `SoundTouchSdkPrivateCfg.xml`. The speaker resolves the hostname at connect time, not at migration time. This means migration can proceed even if the hostname is not yet reachable — for example, when the service will be deployed under that hostname but is not running yet. A warning is still shown in the UI so you are aware, but the Confirm Migration button remains enabled.

### ❌ "Cannot resolve target hostname for migration"

**Symptoms** (migration log or web UI warning):
```
cannot resolve target hostname for migration: cannot resolve "soundtouch.local":
SSH ping from device failed and service-side DNS lookup also failed
```
or:
```
resolved "soundtouch.local" to 192.0.2.100 from service, not from device —
result may be wrong if NAT or split-DNS is in use
```

**What this means:**

The service could not confirm the IP by running `ping` on the speaker via SSH. Either:
- the `ping` binary is not available or not in `$PATH` on this firmware, or
- the hostname is not resolvable from the speaker's network context.

**Diagnosis — run manually over SSH:**

```bash
# SSH into the speaker
ssh root@<speaker-ip>

# Try to resolve the service hostname
ping -c 1 soundtouch.local
# or use the IP directly to verify connectivity
ping -c 1 192.0.2.100

# Check the speaker's current DNS config
cat /etc/resolv.conf

# Check if ping is available
which ping
busybox ping --help
```

**Solutions:**

#### 1. Use an IP address as SERVER_URL

The most reliable fix. If the hostname cannot be resolved from the device, use a raw IP instead. Resolution is skipped entirely when `SERVER_URL` contains an IP.

```bash
# In your .env
SERVER_URL=http://192.0.2.100:8000
HTTPS_SERVER_URL=https://192.0.2.100:8443
```

HTTPS works correctly with IP addresses — the service certificate includes the IP as a Subject Alternative Name (SAN).

#### 2. Ensure the hostname resolves on the speaker's network segment

If you use `soundtouch.local`, verify mDNS is working from another device on the same subnet:

```bash
avahi-resolve -n soundtouch.local    # Linux
dns-sd -G v4 soundtouch.local        # macOS
```

#### 3. Use the XML migration method

Select the XML method in the migration UI. It writes the full URL and the speaker resolves it at connect time, so hostname resolution is not required during migration. This also allows migrating to a hostname that is not yet live.

---

## 🛟 **Getting More Help**

### Information to Gather

When reporting issues, include:

```go
// Device information
info, _ := client.GetDeviceInfo()
fmt.Printf("Device: %s %s (ID: %s)\n", info.Type, info.Name, info.DeviceID)

// Network information
network, _ := client.GetNetworkInfo()
fmt.Printf("Network: %+v\n", network)

// Go version and OS
fmt.Printf("Go version: %s\n", runtime.Version())
fmt.Printf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH)
```

### Useful Commands

```bash
# System information
go version
uname -a  # Linux/macOS
systeminfo # Windows

# Network debugging
ip addr show        # Linux
ifconfig           # macOS
ipconfig /all      # Windows

# SoundTouch specific
go run ./cmd/soundtouch-cli -host <ip> -info
go run ./cmd/soundtouch-cli -host <ip> -network-info
```

### Support Resources

- **GitHub Issues**: Create detailed issue with logs and system info
- **Documentation**: Check `/docs` directory for specific topics
- **Examples**: Review `/examples` for working code patterns
- **CLI Tool**: Use built-in CLI for testing and debugging

Remember: Most issues are network-related. Start with basic connectivity testing before investigating code issues.
