fix(setup): seed audionotification host(s) into /etc/hosts for /speaker TTS

soundcork#104 confirms speakers validate the /speaker audio-notification
app_key against audionotification.api.bosecm.com (100 calls/day on real
Bose). Our /v1/auth shim accepts it, but a host-seeded migration only
worked if the speaker resolved that host to us. DNS interception already
covers it (bosecm.com substring), but the /etc/hosts migration domain
list did not — so the speaker method would fail on hosts-based setups.

Seed both audionotification.api.bosecm.com and the dev variant
(audionotificationdev.api.bosecm.com; firmware may use either) into the
migration /etc/hosts lists, and update the mock fixtures/docs accordingly.
/v1/auth is path-based, so it already answers regardless of which host the
speaker thinks it is calling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-31 22:35:31 +02:00
co-authored by Claude Opus 4.8
parent 80cfb03f6e
commit 382c68d2b6
4 changed files with 27 additions and 7 deletions
@@ -339,9 +339,15 @@ box. soundtouch-web proxies it to the service, so start it with `--service-url`
### Notes and limitations
- **The `app_key` still applies.** Cloud TTS does not bypass the `/speaker`
requirement; without a working `app_key` the speaker will reject playback.
- **Model support** is the same as the direct path (primarily ST-10 Series III).
- **`app_key` validation is handled automatically (speaker method).** The
speaker validates the key by calling `GET /v1/auth` on Bose's audio
notification host (`audionotification.api.bosecm.com`, and a `…dev…` variant),
which AfterTouch intercepts (DNS substring match on `bosecm.com`, plus
`/etc/hosts` seeding during migration) and answers `200`. So any non-empty
`app_key` works; you don't need a real Bose-issued key. The `radio` method
needs no `app_key` at all.
- **Model support** for the `speaker` method is the same as the direct `/speaker`
path (primarily ST-10 Series III). Use `--method radio` on models without it.
- **Reachability:** the speaker must be able to reach the service's
`/media/tts/{id}` URL. The service builds it from its configured `server-url`.
- Synthesized clips are cached in memory for a short time and identical requests
+1 -1
View File
@@ -412,7 +412,7 @@ func (m *mockSSH) Run(command string) (string, error) {
m.runCount++
if m.runCount > 1 {
// Return updated hosts for verification
return "127.0.0.1 localhost\n192.0.2.100\tstreaming.bose.com\n192.0.2.100\tupdates.bose.com\n192.0.2.100\tstats.bose.com\n192.0.2.100\tbmx.bose.com\n192.0.2.100\tcontent.api.bose.io\n192.0.2.100\tevents.api.bosecm.com\n192.0.2.100\tbose-prod.apigee.net\n192.0.2.100\tworldwide.bose.com\n192.0.2.100\tmedia.bose.io\n192.0.2.100\tdownloads.bose.com\n192.0.2.100\tvoice.api.bose.io", nil
return "127.0.0.1 localhost\n192.0.2.100\tstreaming.bose.com\n192.0.2.100\tupdates.bose.com\n192.0.2.100\tstats.bose.com\n192.0.2.100\tbmx.bose.com\n192.0.2.100\tcontent.api.bose.io\n192.0.2.100\tevents.api.bosecm.com\n192.0.2.100\taudionotification.api.bosecm.com\n192.0.2.100\taudionotificationdev.api.bosecm.com\n192.0.2.100\tbose-prod.apigee.net\n192.0.2.100\tworldwide.bose.com\n192.0.2.100\tmedia.bose.io\n192.0.2.100\tdownloads.bose.com\n192.0.2.100\tvoice.api.bose.io", nil
}
return "127.0.0.1 localhost", nil
}
+14
View File
@@ -426,6 +426,13 @@ func (m *Manager) populatePlannedNetworkConfig(summary *MigrationSummary, _, tar
"bmx.bose.com",
"content.api.bose.io",
"events.api.bosecm.com",
// app_key validation for /speaker audio notifications (TTS). Without
// these redirects the speaker validates against the dead Bose cloud and
// reports an invalid app key. Both the prod and dev hosts are seeded
// (firmware may use either). DNS interception already covers them via
// the bosecm.com substring; seed here for /etc/hosts migrations too.
"audionotification.api.bosecm.com",
"audionotificationdev.api.bosecm.com",
"bose-prod.apigee.net",
"worldwide.bose.com",
"music.api.bose.com",
@@ -1424,6 +1431,13 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) {
"bmx.bose.com",
"content.api.bose.io",
"events.api.bosecm.com",
// app_key validation for /speaker audio notifications (TTS). Without
// these redirects the speaker validates against the dead Bose cloud and
// reports an invalid app key. Both the prod and dev hosts are seeded
// (firmware may use either). DNS interception already covers them via
// the bosecm.com substring; seed here for /etc/hosts migrations too.
"audionotification.api.bosecm.com",
"audionotificationdev.api.bosecm.com",
"bose-prod.apigee.net",
"worldwide.bose.com",
"media.bose.io",
+3 -3
View File
@@ -154,7 +154,7 @@ func TestMigrateViaHosts(t *testing.T) {
if command == "cat /etc/hosts" {
// Handle both initial read and verification read
if len(runCalls) > 2 { // Rough heuristic: verification happens after upload
return "192.0.2.100\tstreaming.bose.com\n192.0.2.100\tupdates.bose.com\n192.0.2.100\tstats.bose.com\n192.0.2.100\tbmx.bose.com\n192.0.2.100\tcontent.api.bose.io\n192.0.2.100\tevents.api.bosecm.com\n192.0.2.100\tbose-prod.apigee.net\n192.0.2.100\tworldwide.bose.com\n192.0.2.100\tmedia.bose.io\n192.0.2.100\tdownloads.bose.com\n192.0.2.100\tvoice.api.bose.io", nil
return "192.0.2.100\tstreaming.bose.com\n192.0.2.100\tupdates.bose.com\n192.0.2.100\tstats.bose.com\n192.0.2.100\tbmx.bose.com\n192.0.2.100\tcontent.api.bose.io\n192.0.2.100\tevents.api.bosecm.com\n192.0.2.100\taudionotification.api.bosecm.com\n192.0.2.100\taudionotificationdev.api.bosecm.com\n192.0.2.100\tbose-prod.apigee.net\n192.0.2.100\tworldwide.bose.com\n192.0.2.100\tmedia.bose.io\n192.0.2.100\tdownloads.bose.com\n192.0.2.100\tvoice.api.bose.io", nil
}
return "127.0.0.1 localhost", nil
}
@@ -232,7 +232,7 @@ func TestMigrateViaHosts_UpdateExisting(t *testing.T) {
runCount++
if command == "cat /etc/hosts" {
if runCount > 1 {
return "127.0.0.1 localhost\n192.0.2.100\tstreaming.bose.com\n192.0.2.100\tupdates.bose.com\n192.0.2.100\tstats.bose.com\n192.0.2.100\tbmx.bose.com\n192.0.2.100\tcontent.api.bose.io\n192.0.2.100\tevents.api.bosecm.com\n192.0.2.100\tbose-prod.apigee.net\n192.0.2.100\tworldwide.bose.com\n192.0.2.100\tmedia.bose.io\n192.0.2.100\tdownloads.bose.com\n192.0.2.100\tvoice.api.bose.io", nil
return "127.0.0.1 localhost\n192.0.2.100\tstreaming.bose.com\n192.0.2.100\tupdates.bose.com\n192.0.2.100\tstats.bose.com\n192.0.2.100\tbmx.bose.com\n192.0.2.100\tcontent.api.bose.io\n192.0.2.100\tevents.api.bosecm.com\n192.0.2.100\taudionotification.api.bosecm.com\n192.0.2.100\taudionotificationdev.api.bosecm.com\n192.0.2.100\tbose-prod.apigee.net\n192.0.2.100\tworldwide.bose.com\n192.0.2.100\tmedia.bose.io\n192.0.2.100\tdownloads.bose.com\n192.0.2.100\tvoice.api.bose.io", nil
}
return "127.0.0.1 localhost\n1.2.3.4\tstreaming.bose.com\n1.2.3.4\tupdates.bose.com", nil
}
@@ -771,7 +771,7 @@ func TestMigrateViaHosts_SkipCAIfTrusted(t *testing.T) {
if command == "cat /etc/hosts" {
// Handle both initial read and verification read
if len(runCalls) > 2 { // Rough heuristic: verification happens after upload
return "192.0.2.100\tstreaming.bose.com\n192.0.2.100\tupdates.bose.com\n192.0.2.100\tstats.bose.com\n192.0.2.100\tbmx.bose.com\n192.0.2.100\tcontent.api.bose.io\n192.0.2.100\tevents.api.bosecm.com\n192.0.2.100\tbose-prod.apigee.net\n192.0.2.100\tworldwide.bose.com\n192.0.2.100\tmedia.bose.io\n192.0.2.100\tdownloads.bose.com\n192.0.2.100\tvoice.api.bose.io", nil
return "192.0.2.100\tstreaming.bose.com\n192.0.2.100\tupdates.bose.com\n192.0.2.100\tstats.bose.com\n192.0.2.100\tbmx.bose.com\n192.0.2.100\tcontent.api.bose.io\n192.0.2.100\tevents.api.bosecm.com\n192.0.2.100\taudionotification.api.bosecm.com\n192.0.2.100\taudionotificationdev.api.bosecm.com\n192.0.2.100\tbose-prod.apigee.net\n192.0.2.100\tworldwide.bose.com\n192.0.2.100\tmedia.bose.io\n192.0.2.100\tdownloads.bose.com\n192.0.2.100\tvoice.api.bose.io", nil
}
return "127.0.0.1 localhost", nil
}