Compare commits

...
19 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Sonnet 4.6 b040c8a90c feat(soundtouch-web): add multi-room zone management
Backend:
- GET /api/zone/{id} — zone info enriched with device names and role flags
- POST /api/zone/{id}/add/{slaveId} — add slave (creates zone if standalone)
- POST /api/zone/{id}/remove/{slaveId} — remove slave from zone
- POST /api/zone/{id}/dissolve — dissolve zone to standalone
- POST /api/zone/{id}/leave — slave leaves its zone (backend finds master)

Frontend (Zone.js):
- Standalone: shows "Group with…" button, opens device picker overlay
- Master: member list with per-row Remove, Add speaker, Dissolve buttons
- Slave: shows master name, Leave zone button
- Lazy-loads on device detail open; refreshes after each zone operation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:22:57 +02:00
Tobias GesellchenandClaude Sonnet 4.6 3122c4ed3a feat(soundtouch-web): add recents panel with play support
- GET /api/device-recents/{id} — fetches /recents from device
- POST /api/device-play/{id} — generic content-item player (reusable)
- Recents.js: lazy-loaded list with artwork, name, source badge, click-to-play
- Hides itself when the device returns no recents
- api.js: recents() and play() helpers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:22:57 +02:00
Tobias GesellchenandClaude Sonnet 4.6 2edcc14342 feat(soundtouch-web): add progress bar, shuffle/repeat, and bass controls
- NowPlaying: progress bar with live ticking (resets on position/state change)
- Controls: shuffle toggle (🔀), repeat cycle (🔁/🔂), active state styling
- Controls: bass slider (-9..+9), shown only when device reports bass support
- api.js: add bass() helper posting JSON body to /api/control/{id}/bass
- CSS: progress bar, progress time, bass-row styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:22:57 +02:00
Tobias GesellchenandClaude Sonnet 4.6 6723515f54 feat(soundtouch-web): migrate to pkg/service/soundtouchweb with Preact UI
Move soundtouch-web from Bootstrap+vanilla JS to an embedded-asset Go service
using Preact+htm (no build step). Implements Stockholm UI parity: device list,
now playing, transport controls, presets, sources, and TuneIn browser.

- Relocate handlers/websocket/webtypes to pkg/service/soundtouchweb/
- Replace old static/ with CSS-custom-property design system (dark mode)
- Add Preact component tree: DeviceList, NowPlaying, Controls, Presets, Sources, TuneInBrowser
- Wire WebSocket for real-time device status updates
- Slim cmd/soundtouch-web/main.go to a thin CLI wrapper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:22:57 +02:00
Tobias Gesellchen 396b359c11 Update to Golang 1.26.3 (#230)
See https://go.dev/doc/devel/release#go1.26.3 and
https://groups.google.com/g/golang-dev/c/h6eZjndBMqQ
2026-05-08 21:21:52 +02:00
Tobias GesellchenandClaude Opus 4.7 969bdf8704 feat(service): add --discovery-enabled CLI flag and treat 0 interval as disabled (#229)
Why: Operators need to control device discovery from the command line
without touching the persisted settings file, and a zero discovery
interval should be unambiguously off rather than running an
immediate-fire scan loop.

- Add --discovery-enabled BoolFlag (default true, env DISCOVERY_ENABLED)
and thread it through serviceConfig, applyPersistedSettings, and
createDefaultSettings so CLI/env can seed initial state and persisted
settings still take precedence on subsequent runs.
- HandleUpdateSettings now forces discoveryEnabled=false whenever the
resulting discoveryInterval is zero.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:18:15 +02:00
Tobias GesellchenandClaude Opus 4.7 ac5e67d198 fix(client): default sourceAccount to "AUX" for AUX source selection (#228)
The speaker rejects /select with source="AUX" and an empty sourceAccount
as INVALID_SOURCE, so the audio path never reaches APAuxSrc. Default the
sourceAccount to "AUX" inside SelectSource and align ItemName to "AUX
IN" to match the device's own button-press payload.

Relates to https://github.com/gesellix/Bose-SoundTouch/issues/195

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:11:00 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ea4d8bacac revert(setup): revert OverrideSdkPrivateCfg.xml migration approach (#220)
The OverrideSdkPrivateCfg.xml override path introduced in #209 does not
work on SoundTouch 10 (and likely other models): the firmware ignores
the override file, leaving the device pointing at the original Bose
cloud URLs. Revert to editing SoundTouchSdkPrivateCfg.xml directly with
a .original backup, which is the approach known to work.

Relates to #214

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 08:43:34 +02:00
Tobias GesellchenandClaude Opus 4.7 bcffbc7719 fix(setup): test override file existence before treating cat output as config (#215)
client.Run uses CombinedOutput, so when
`/mnt/nv/OverrideSdkPrivateCfg.xml` is absent (the default for devices
migrated with pre-0.71.0 code) the cat stderr is returned as the
override config and surfaced to the migration page UI as "Current Config
(on Speaker)". Gate the branch on `[ -f ... ]` first, mirroring the
legacy .original check.

Relates to #209
Relates to #214

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:06:31 +02:00
Tobias GesellchenandClaude Sonnet 4.6 d14f4691a6 fix(amazon): use email and AMAZON type to match old Bose cloud format (#212)
Store the user's email address (not Amazon account ID) in
sourceKey.account and set source type to "AMAZON" so the speaker
firmware recognises Amazon Music sources the same way as the original
Bose cloud.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 08:23:04 +02:00
Tobias GesellchenandClaude Sonnet 4.6 a1d0add5a1 feat(ui): add CA certificate download to Settings tab and Migration tab (#210)
Adds a "Download CA Certificate" button in the Settings tab
(system-level convenience for importing the cert into browsers, curl,
Python clients, etc.) and a "Download CA cert" link next to the existing
"Trust CA Now" button in the Migration tab. Both link to the existing
/setup/ca.crt endpoint.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 20:51:40 +02:00
Tobias GesellchenandClaude Sonnet 4.6 8b196a8260 fix(setup): write XML migration to OverrideSdkPrivateCfg.xml instead of editing original (#209)
Use /mnt/nv/OverrideSdkPrivateCfg.xml (the firmware's override path)
rather than editing /opt/Bose/etc/SoundTouchSdkPrivateCfg.xml directly.
A malformed override cannot cause a reboot loop because the device falls
back to the untouched original.

Revert now removes the override file; legacy .original backups are still
restored for devices migrated with older code. checkCurrentConfig reads
the override path first so IsMigrated detection works correctly with the
new approach.

Credit: Ueberbose team, discovered via [soundcork
documentation](https://github.com/deborahgu/soundcork#configuring-the-bose-speaker-to-use-the-soundcork-server).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 20:51:05 +02:00
Tobias GesellchenandClaude Sonnet 4.6 3be654da17 chore(build): add -trimpath to GitHub workflow build commands
Consistent with the Makefile which already applies -trimpath globally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:55:14 +02:00
Tobias GesellchenandClaude Sonnet 4.6 3891c08dd1 docs(migration): add Docker Compose quickstart with .env config guidance
Adds a "Docker Compose (recommended for home servers and VMs)" section
to Step 1, pointing users to the existing docker-compose.yml and
.env.example. Clarifies the purpose of docker-compose.ci.yml (CI tests
only) and docker-compose.override.yml (local modifications, not in VCS).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:36:34 +02:00
Tobias GesellchenandClaude Sonnet 4.6 f6e733de6b fix(certmanager): use hostname as server cert CN instead of a random Bose domain
domains[0] was non-deterministic (Go map iteration) and could resolve to
any domain in the list including Bose-owned domains. Adds CommonName field
to CertificateManager, defaulting to "localhost", set to the device hostname
at startup. All Bose domains remain in the SAN where clients actually look.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:15:48 +02:00
Tobias GesellchenandClaude Sonnet 4.6 84585b8034 perf(service): move TLS cert generation off the startup path
On constrained hardware (e.g. ARMv7), RSA key generation can block
startup for minutes. HTTP now starts immediately; HTTPS is brought up
in a background goroutine once cert generation completes. A log message
informs the user that HTTPS will be available shortly after startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:11:41 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ac44fdace6 perf(certmanager): reduce CA key size from RSA-4096 to RSA-2048
RSA-4096 CA generation blocks service startup for minutes on slow ARM
hardware. The CA key is only used to sign server certs, never in TLS
handshakes, so 2048 bits provides sufficient security for a local CA
while being ~4-8x faster to generate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:11:41 +02:00
Tobias GesellchenandClaude Sonnet 4.6 06042f8728 chore(build): add ARMv7 target and apply trimpath/-s/-w flags globally
Adds build-linux-armv7 target (GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0)
for deployment to old embedded Linux devices (kernel 3.14+). Introduces
BUILDFLAGS=-trimpath -ldflags="-s -w" applied to all build targets for
smaller, reproducible binaries without local path leakage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:11:41 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ca19bb32f7 feat(setup): harden hostname resolution before migration (#204)
- resolveIP now returns (string, error): error when result did not come
  from the device's own SSH ping (service-side fallback or total
failure)
- migrateViaResolvConf and parseTargetURLAndResolveIP abort on error,
  preventing a bad IP from being written to the device
- GetMigrationSummary captures the error in ResolveIPError and falls
back
  to the hostname for the preview display; XML migration is unaffected
- Web UI shows a warning box with the error and a docs link when
resolution
  is uncertain; migrate button stays enabled for the XML method
- Add hostname resolution troubleshooting section to TROUBLESHOOTING.md

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 20:00:24 +02:00
51 changed files with 2844 additions and 3515 deletions
+2 -2
View File
@@ -112,7 +112,7 @@ jobs:
if [ "${{ matrix.goos }}" = "windows" ]; then
output_name="${output_name}.exe"
fi
go build -o "$output_name" ./cmd/soundtouch-cli
go build -trimpath -ldflags="-s -w" -o "$output_name" ./cmd/soundtouch-cli
- name: Upload build artifacts
uses: actions/upload-artifact@v7
@@ -220,7 +220,7 @@ jobs:
- name: Test CLI build and help
run: |
go build -o soundtouch-cli ./cmd/soundtouch-cli
go build -trimpath -ldflags="-s -w" -o soundtouch-cli ./cmd/soundtouch-cli
./soundtouch-cli -help
- name: Test library imports
+1
View File
@@ -151,6 +151,7 @@ jobs:
rm -f "$OUTPUT_NAME" "$OUTPUT_NAME.sha256" "$OUTPUT_NAME.sha512"
if ! go build \
-trimpath \
-ldflags="-s -w" \
-o "$OUTPUT_NAME" \
"$CMD_PATH"; then
+1 -1
View File
@@ -1,5 +1,5 @@
# Build stage
FROM --platform=$BUILDPLATFORM golang:1.26.2-alpine AS builder
FROM --platform=$BUILDPLATFORM golang:1.26.3-alpine AS builder
# Declare automatic platform ARGs to make them available in build stage
# See https://docs.docker.com/reference/dockerfile#automatic-platform-args-in-the-global-scope
+43 -35
View File
@@ -28,8 +28,8 @@ BACKUP_NAME=soundtouch-backup
BACKUP_PATH=./cmd/$(BACKUP_NAME)
BUILD_DIR=./build
# Version info
# No ldflags needed - using debug.BuildInfo since Go 1.18
# Build flags: strip debug info/DWARF for smaller binaries, remove local paths for reproducibility
BUILDFLAGS=-trimpath -ldflags="-s -w"
all: check build
@@ -38,78 +38,85 @@ build: build-cli build-service build-web build-examples build-favicon-gen build-
build-cli:
@echo "Building $(BINARY_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME) $(BINARY_PATH)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) $(BINARY_PATH)
build-service:
@echo "Building $(SERVICE_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME) $(SERVICE_PATH)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME) $(SERVICE_PATH)
build-web:
@echo "Building $(WEB_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) -o $(BUILD_DIR)/$(WEB_NAME) $(WEB_PATH)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(WEB_NAME) $(WEB_PATH)
build-examples:
@echo "Building $(EXAMPLE_MDNS_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME) $(EXAMPLE_MDNS_PATH)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME) $(EXAMPLE_MDNS_PATH)
@echo "Building $(EXAMPLE_UPNP_NAME)..."
$(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME) $(EXAMPLE_UPNP_PATH)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME) $(EXAMPLE_UPNP_PATH)
@echo "Building $(SCANNER_NAME)..."
$(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH)
build-favicon-gen:
@echo "Building $(FAVICON_GEN_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) -o $(BUILD_DIR)/$(FAVICON_GEN_NAME) $(FAVICON_GEN_PATH)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(FAVICON_GEN_NAME) $(FAVICON_GEN_PATH)
build-backup:
@echo "Building $(BACKUP_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) -o $(BUILD_DIR)/$(BACKUP_NAME) $(BACKUP_PATH)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME) $(BACKUP_PATH)
build-all: build-linux build-darwin build-windows build-examples-all
build-all: build-linux build-linux-armv7 build-darwin build-windows build-examples-all
build-linux:
@echo "Building for Linux..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(BINARY_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-linux-amd64 $(SERVICE_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BACKUP_NAME)-linux-amd64 $(BACKUP_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(BINARY_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-linux-amd64 $(SERVICE_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-linux-amd64 $(BACKUP_PATH)
build-linux-armv7:
@echo "Building for Linux ARMv7 (CGO_ENABLED=0 for kernel 3.14+ compatibility)..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-linux-armv7 $(SERVICE_PATH)
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 $(BINARY_PATH)
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-linux-armv7 $(BACKUP_PATH)
build-darwin:
@echo "Building for macOS..."
@mkdir -p $(BUILD_DIR)
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(BINARY_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 $(BINARY_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-amd64 $(SERVICE_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-arm64 $(SERVICE_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BACKUP_NAME)-darwin-amd64 $(BACKUP_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(BACKUP_NAME)-darwin-arm64 $(BACKUP_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(BINARY_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 $(BINARY_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-amd64 $(SERVICE_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-arm64 $(SERVICE_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-darwin-amd64 $(BACKUP_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-darwin-arm64 $(BACKUP_PATH)
build-windows:
@echo "Building for Windows..."
@mkdir -p $(BUILD_DIR)
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(BINARY_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SERVICE_NAME)-windows-amd64.exe $(SERVICE_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BACKUP_NAME)-windows-amd64.exe $(BACKUP_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(BINARY_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-windows-amd64.exe $(SERVICE_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-windows-amd64.exe $(BACKUP_PATH)
build-examples-all:
@echo "Building examples for all platforms..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-linux-amd64 $(EXAMPLE_MDNS_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-amd64 $(EXAMPLE_MDNS_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-arm64 $(EXAMPLE_MDNS_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-windows-amd64.exe $(EXAMPLE_MDNS_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-linux-amd64 $(EXAMPLE_UPNP_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-amd64 $(EXAMPLE_UPNP_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-arm64 $(EXAMPLE_UPNP_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-windows-amd64.exe $(EXAMPLE_UPNP_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-linux-amd64 $(SCANNER_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-amd64 $(SCANNER_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-arm64 $(SCANNER_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME)-windows-amd64.exe $(SCANNER_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-linux-amd64 $(EXAMPLE_MDNS_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-amd64 $(EXAMPLE_MDNS_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-arm64 $(EXAMPLE_MDNS_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-windows-amd64.exe $(EXAMPLE_MDNS_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-linux-amd64 $(EXAMPLE_UPNP_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-amd64 $(EXAMPLE_UPNP_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-arm64 $(EXAMPLE_UPNP_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-windows-amd64.exe $(EXAMPLE_UPNP_PATH)
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-linux-amd64 $(SCANNER_PATH)
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-amd64 $(SCANNER_PATH)
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-arm64 $(SCANNER_PATH)
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-windows-amd64.exe $(SCANNER_PATH)
test:
@echo "Running tests..."
@@ -338,6 +345,7 @@ help:
@echo " build-favicon-gen - Build the favicon generator"
@echo " build-examples - Build only the example programs"
@echo " build-all - Build for all platforms"
@echo " build-linux-armv7 - Build for Linux ARMv7 (kernel 3.14+ compatible, CGO_ENABLED=0)"
@echo " test - Run tests"
@echo " test-coverage - Run tests with coverage report"
@echo " check - Run fmt, vet, and tests"
+30 -11
View File
@@ -240,6 +240,12 @@ func main() {
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",
@@ -387,7 +393,7 @@ func main() {
config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname)
cm := initCertificateManager(config.dataDir)
cm := initCertificateManager(config.dataDir, config.hostname)
sm := setup.NewManager(config.serverURL, ds, cm)
sm.MgmtUsername = config.mgmtUsername
sm.MgmtPassword = config.mgmtPassword
@@ -395,7 +401,7 @@ func main() {
sm.GetDNSRunning = server.GetDNSRunning
server.SetHTTPServerURL(config.httpsServerURL)
server.SetVersionInfo(version, commit, date, repoURL)
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
server.SetDiscoverySettings(config.discoveryInterval, config.discoveryEnabled)
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints, persisted.SkipMirrorEndpoints, persisted.PreferredSource)
server.SetInternalPaths(persisted.InternalPaths)
@@ -460,20 +466,25 @@ func main() {
initializeDefaultSources(ds)
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
if err != nil {
log.Printf("Warning: Failed to setup TLS: %v", err)
}
startDeviceDiscovery(server)
r := setupRouter(server)
log.Printf("Go service starting on %s", config.serverURL)
if tlsConfig != nil {
// TLS cert generation can be slow on constrained hardware; run it in the
// background so the HTTP server is available immediately.
log.Printf("HTTPS setup running in background; %s will be available shortly", config.httpsServerURL)
go func() {
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
if err != nil {
log.Printf("Warning: Failed to setup TLS: %v", err)
return
}
startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL)
}
}()
return http.ListenAndServe(config.addr, r)
},
@@ -507,6 +518,7 @@ type serviceConfig struct {
bindAddr string
addr string
dataDir string
hostname string
serverURL string
httpsServerURL string
httpsAddr string
@@ -520,6 +532,7 @@ type serviceConfig struct {
mirrorEndpoints []string
skipMirrorEndpoints []string
internalPaths []string
discoveryEnabled bool
discoveryInterval time.Duration
domains []string
spotifyClientID string
@@ -584,6 +597,7 @@ func loadConfig(c *cli.Context) serviceConfig {
dnsUpstream := c.String("dns-upstream")
dnsBind := c.String("dns-bind")
discoveryEnabled := c.Bool("discovery-enabled")
discoveryIntervalStr := c.String("discovery-interval")
discoveryInterval, err := time.ParseDuration(discoveryIntervalStr)
@@ -618,6 +632,7 @@ func loadConfig(c *cli.Context) serviceConfig {
bindAddr: bindAddr,
addr: addr,
dataDir: dataDir,
hostname: hostname,
serverURL: serverURL,
httpsServerURL: httpsServerURL,
httpsAddr: httpsAddr,
@@ -631,6 +646,7 @@ func loadConfig(c *cli.Context) serviceConfig {
mirrorEndpoints: mirrorEndpoints,
skipMirrorEndpoints: skipMirrorEndpoints,
internalPaths: internalPaths,
discoveryEnabled: discoveryEnabled,
discoveryInterval: discoveryInterval,
domains: domains,
spotifyClientID: spotifyClientID,
@@ -713,6 +729,7 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
config.httpsServerURL = persisted.HTTPServerURL
}
config.discoveryEnabled = persisted.DiscoveryEnabled
if persisted.DiscoveryInterval != "" {
if d, durErr := time.ParseDuration(persisted.DiscoveryInterval); durErr == nil {
config.discoveryInterval = d
@@ -779,8 +796,8 @@ func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datast
RedactLogs: config.redact,
LogBodies: config.logBody,
RecordInteractions: config.record,
DiscoveryEnabled: config.discoveryEnabled,
DiscoveryInterval: config.discoveryInterval.String(),
DiscoveryEnabled: true,
DNSEnabled: config.dnsEnabled,
DNSUpstream: strings.Split(config.dnsUpstream, ","),
DNSBindAddr: config.dnsBind,
@@ -809,8 +826,10 @@ func initDataStore(dataDir string) *datastore.DataStore {
return ds
}
func initCertificateManager(dataDir string) *certmanager.CertificateManager {
func initCertificateManager(dataDir, hostname string) *certmanager.CertificateManager {
cm := certmanager.NewCertificateManager(filepath.Join(dataDir, "certs"))
cm.CommonName = hostname
if err := cm.EnsureCA(); err != nil {
log.Printf("Warning: Failed to ensure CA: %v", err)
}
-648
View File
@@ -1,648 +0,0 @@
// Package handlers contains HTTP handlers for the SoundTouch web UI.
package handlers
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"sync"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/models"
bmxpkg "github.com/gesellix/bose-soundtouch/pkg/service/bmx"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
)
// WebApp holds the application state and dependencies
type WebApp struct {
Devices map[string]*webtypes.DeviceConnection
Upgrader websocket.Upgrader
WSClients map[*websocket.Conn]bool
WSMutex sync.RWMutex
}
// NewWebApp creates a new WebApp instance for SPA mode
func NewWebApp() *WebApp {
return &WebApp{
Devices: make(map[string]*webtypes.DeviceConnection),
WSClients: make(map[*websocket.Conn]bool),
Upgrader: websocket.Upgrader{
CheckOrigin: func(_ *http.Request) bool { return true },
},
}
}
// HandleAPIDevices returns all devices as JSON
func (app *WebApp) HandleAPIDevices(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Return all devices as JSON
devices := make(map[string]interface{})
for id, device := range app.Devices {
devices[id] = map[string]interface{}{
"info": device.DeviceInfo,
"status": device.Status,
"lastSeen": device.LastSeen,
}
}
response := webtypes.APIResponse{
Success: true,
Data: devices,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleAPIDevice returns a specific device as JSON
func (app *WebApp) HandleAPIDevice(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
if deviceID == "" {
app.sendError(w, "Device ID required", http.StatusBadRequest)
return
}
device, exists := app.Devices[deviceID]
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
// Update device status to get fresh power state
app.UpdateDeviceStatus(deviceID, device)
// Connect WebSocket for real-time updates if not already connected
if device.WebSocket == nil {
go app.ConnectDeviceWebSocket(deviceID, device)
}
w.Header().Set("Content-Type", "application/json")
response := webtypes.APIResponse{
Success: true,
Data: map[string]interface{}{
"info": device.DeviceInfo,
"status": device.Status,
},
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleAPIControl handles device control commands
func (app *WebApp) HandleAPIControl(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
action := chi.URLParam(r, "action")
if deviceID == "" {
app.sendError(w, "Device ID required", http.StatusBadRequest)
return
}
device, exists := app.Devices[deviceID]
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
// Connect WebSocket for real-time updates if not already connected
if device.WebSocket == nil {
go app.ConnectDeviceWebSocket(deviceID, device)
}
w.Header().Set("Content-Type", "application/json")
app.handleControlAction(w, r, action, device)
}
// handleControlAction processes different control actions
func (app *WebApp) handleControlAction(w http.ResponseWriter, r *http.Request, action string, device *webtypes.DeviceConnection) {
switch action {
case "play":
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.Play()
app.sendControlResponse(w, err, "Started playback")
case "pause":
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.Pause()
app.sendControlResponse(w, err, "Paused playback")
case "stop":
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.Stop()
app.sendControlResponse(w, err, "Stopped playback")
case "next":
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.NextTrack()
app.sendControlResponse(w, err, "Next track")
case "previous":
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.PrevTrack()
app.sendControlResponse(w, err, "Previous track")
case "volume":
app.handleVolumeControl(w, r, device)
case "mute":
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.SendKey(models.KeyMute)
app.sendControlResponse(w, err, "Toggled mute")
case "preset":
app.handlePresetControl(w, r, device)
case "bass":
app.handleBassControl(w, r, device)
case "source":
app.handleSourceControl(w, r, device)
default:
app.sendError(w, "Unknown action", http.StatusBadRequest)
}
}
// handleVolumeControl processes volume control requests
func (app *WebApp) handleVolumeControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
if r.Method != http.MethodPost {
app.sendError(w, "POST required for volume control", http.StatusMethodNotAllowed)
return
}
var volumeReq webtypes.VolumeRequest
if err := json.NewDecoder(r.Body).Decode(&volumeReq); err != nil {
app.sendError(w, "Invalid volume data", http.StatusBadRequest)
return
}
if volumeReq.Level < 0 || volumeReq.Level > 100 {
app.sendError(w, "Volume must be between 0 and 100", http.StatusBadRequest)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.SetVolume(volumeReq.Level)
app.sendControlResponse(w, err, fmt.Sprintf("Volume set to %d", volumeReq.Level))
}
// handlePresetControl processes preset control requests
func (app *WebApp) handlePresetControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
presetParam := r.URL.Query().Get("id")
if presetParam == "" {
app.sendError(w, "Preset ID required", http.StatusBadRequest)
return
}
presetID, err := strconv.Atoi(presetParam)
if err != nil {
app.sendError(w, "Invalid preset ID", http.StatusBadRequest)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err = device.Client.SelectPreset(presetID)
app.sendControlResponse(w, err, fmt.Sprintf("Selected preset %d", presetID))
}
// handleBassControl processes bass control requests
func (app *WebApp) handleBassControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
if r.Method != http.MethodPost {
app.sendError(w, "POST required for bass control", http.StatusMethodNotAllowed)
return
}
var bassReq webtypes.BassRequest
if err := json.NewDecoder(r.Body).Decode(&bassReq); err != nil {
app.sendError(w, "Invalid bass data", http.StatusBadRequest)
return
}
if bassReq.Level < -9 || bassReq.Level > 9 {
app.sendError(w, "Bass must be between -9 and 9", http.StatusBadRequest)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.SetBass(bassReq.Level)
app.sendControlResponse(w, err, fmt.Sprintf("Bass set to %d", bassReq.Level))
}
// handleSourceControl processes source control requests
func (app *WebApp) handleSourceControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
sourceParam := r.URL.Query().Get("name")
if sourceParam == "" {
app.sendError(w, "Source name required", http.StatusBadRequest)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.SelectSource(sourceParam, "")
app.sendControlResponse(w, err, fmt.Sprintf("Selected source %s", sourceParam))
}
// sendControlResponse sends a control command response
func (app *WebApp) sendControlResponse(w http.ResponseWriter, err error, successMessage string) {
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
response := webtypes.APIResponse{
Success: true,
Data: map[string]string{"message": successMessage},
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// sendError sends an error response
func (app *WebApp) sendError(w http.ResponseWriter, message string, statusCode int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
response := webtypes.APIResponse{
Success: false,
Error: message,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode error response", http.StatusInternalServerError)
}
}
// HandleDeviceKey handles sending key commands to devices
func (app *WebApp) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
key := chi.URLParam(r, "key")
device, exists := app.Devices[deviceID]
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
// Connect WebSocket for real-time updates if not already connected
if device.WebSocket == nil {
go app.ConnectDeviceWebSocket(deviceID, device)
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
err := device.Client.SendKey(key)
app.sendControlResponse(w, err, fmt.Sprintf("Sent key command: %s", key))
}
// HandleDirectVolumeControl handles direct volume setting via URL parameter
func (app *WebApp) HandleDirectVolumeControl(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
volumeLevel, err := strconv.Atoi(chi.URLParam(r, "volume"))
if err != nil || volumeLevel < 0 || volumeLevel > 100 {
app.sendError(w, "Invalid volume level (0-100)", http.StatusBadRequest)
return
}
device, exists := app.Devices[deviceID]
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
// Connect WebSocket for real-time updates if not already connected
if device.WebSocket == nil {
go app.ConnectDeviceWebSocket(deviceID, device)
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
err = device.Client.SetVolume(volumeLevel)
app.sendControlResponse(w, err, fmt.Sprintf("Volume set to %d", volumeLevel))
}
// HandleDevicePower handles power toggle commands for devices
func (app *WebApp) HandleDevicePower(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
device, exists := app.Devices[deviceID]
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
// Connect WebSocket for real-time updates if not already connected
if device.WebSocket == nil {
go app.ConnectDeviceWebSocket(deviceID, device)
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
// Send POWER key command to toggle device power
err := device.Client.SendKey("POWER")
app.sendControlResponse(w, err, "Power toggle command sent")
}
// HandleDevicePowerStatus handles lightweight power status check
func (app *WebApp) HandleDevicePowerStatus(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
device, exists := app.Devices[deviceID]
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
// Quick power status check by getting now playing
nowPlaying, err := device.Client.GetNowPlaying()
if err != nil {
app.sendControlResponse(w, err, "Failed to get power status")
return
}
isPoweredOn := nowPlaying != nil && nowPlaying.Source != "STANDBY"
response := webtypes.APIResponse{
Success: true,
Data: map[string]interface{}{
"deviceId": deviceID,
"isPoweredOn": isPoweredOn,
"source": nowPlaying.Source,
},
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// BroadcastDeviceList sends updated device list to all connected WebSocket clients
func (app *WebApp) BroadcastDeviceList() {
app.WSMutex.RLock()
defer app.WSMutex.RUnlock()
devices := make(map[string]interface{})
for id, device := range app.Devices {
devices[id] = map[string]interface{}{
"info": device.DeviceInfo,
"status": device.Status,
"lastSeen": device.LastSeen,
}
}
message := webtypes.WebSocketMessage{
Type: "devices",
Data: devices,
}
// Send to all connected clients
var failedClients []*websocket.Conn
for client := range app.WSClients {
if err := client.WriteJSON(message); err != nil {
log.Printf("Failed to send device update to WebSocket client: %v", err)
// Mark for removal to avoid modifying map during iteration
failedClients = append(failedClients, client)
}
}
// Remove failed clients
for _, client := range failedClients {
delete(app.WSClients, client)
client.Close()
}
}
// BroadcastDiscoveryStatus sends discovery progress updates to all connected WebSocket clients
func (app *WebApp) BroadcastDiscoveryStatus(status string, deviceCount int) {
app.WSMutex.RLock()
defer app.WSMutex.RUnlock()
message := webtypes.WebSocketMessage{
Type: "discovery_status",
Data: map[string]interface{}{
"status": status,
"deviceCount": deviceCount,
},
}
// Send to all connected clients
var failedClients []*websocket.Conn
for client := range app.WSClients {
if err := client.WriteJSON(message); err != nil {
log.Printf("Failed to send discovery status to WebSocket client: %v", err)
// Mark for removal to avoid modifying map during iteration
failedClients = append(failedClients, client)
}
}
// Remove failed clients
for _, client := range failedClients {
delete(app.WSClients, client)
client.Close()
}
}
// HandleTuneInSearch handles TuneIn search requests, proxying directly to the bmx package.
func (app *WebApp) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
app.sendError(w, "query parameter 'q' is required", http.StatusBadRequest)
return
}
resp, err := bmxpkg.TuneInSearch(query)
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: resp}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleTuneInNavigate handles TuneIn browse/navigate requests, proxying directly to the bmx package.
// Supported path suffixes (relative to /api/tunein/navigate):
// - (empty) → top-level browse
// - /{encodedURI} → browse the given TuneIn URI
// - /sub/{n}/{encodedURI} → single subsection
// - /profiles/{type}/{id}/{encodedURI} → artist/program profile
func (app *WebApp) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
wildcard := chi.URLParam(r, "*")
var (
resp interface{}
err error
)
if wildcard == "" {
resp, err = bmxpkg.TuneInNavigate("", nil)
} else {
firstSlash := strings.Index(wildcard, "/")
if firstSlash == -1 {
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
} else {
pfx := wildcard[:firstSlash]
rest := wildcard[firstSlash+1:]
switch pfx {
case "sub":
secondSlash := strings.Index(rest, "/")
if secondSlash == -1 {
resp, err = bmxpkg.TuneInNavigate(rest, nil)
} else {
n, parseErr := strconv.Atoi(rest[:secondSlash])
if parseErr != nil {
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
} else {
resp, err = bmxpkg.TuneInNavigate(rest[secondSlash+1:], &n)
}
}
case "profiles":
parts := strings.SplitN(rest, "/", 3)
if len(parts) < 3 {
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
} else {
resp, err = bmxpkg.TuneInNavigateProfile(parts[2])
}
default:
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
}
}
}
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: resp}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandlePlayTuneIn plays a TuneIn content item on a specific device via POST /select.
func (app *WebApp) HandlePlayTuneIn(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
if deviceID == "" {
app.sendError(w, "Device ID required", http.StatusBadRequest)
return
}
device, exists := app.Devices[deviceID]
if !exists {
app.sendError(w, fmt.Sprintf("Device '%s' not found", deviceID), http.StatusNotFound)
return
}
var req struct {
Location string `json:"location"`
Name string `json:"name"`
Type string `json:"type"`
ContainerArt string `json:"containerArt"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
app.sendError(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Location == "" {
app.sendError(w, "location is required", http.StatusBadRequest)
return
}
itemType := req.Type
if itemType == "" {
itemType = "stationurl"
}
contentItem := &models.ContentItem{
Source: "TUNEIN",
Type: itemType,
Location: req.Location,
ItemName: req.Name,
IsPresetable: true,
ContainerArt: req.ContainerArt,
}
if err := device.Client.SelectContentItem(contentItem); err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: map[string]string{"message": "Playing " + req.Name}}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
+4 -164
View File
@@ -2,26 +2,15 @@
package main
import (
"context"
"embed"
"io/fs"
"log"
"net/http"
"os"
"time"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/config"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
"github.com/go-chi/chi/v5"
"github.com/urfave/cli/v2"
)
//go:embed static
var staticFS embed.FS
func main() {
app := &cli.App{
Name: "soundtouch-web",
@@ -49,36 +38,10 @@ func main() {
addr = bindAddr + ":" + port
}
// Create web app without templates (SPA mode)
webApp := handlers.NewWebApp()
webApp := soundtouchweb.New()
// Initialize discovery service
cfg, err := config.LoadFromEnv()
if err != nil {
log.Printf("Failed to load config: %v, using defaults", err)
cfg = config.DefaultConfig()
}
cfg.DiscoveryTimeout = 10 * time.Second
cfg.CacheEnabled = true
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
// Discover devices on startup
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
webApp.BroadcastDiscoveryStatus("starting", len(webApp.Devices))
discoverDevices(ctx, webApp, discoveryService)
webApp.BroadcastDiscoveryStatus("completed", len(webApp.Devices))
webApp.BroadcastDeviceList()
}()
r := setupRoutes(webApp, discoveryService)
r := chi.NewRouter()
webApp.Mount(r)
log.Printf("SoundTouch Web UI starting on http://%s", addr)
@@ -90,126 +53,3 @@ func main() {
log.Fatal(err)
}
}
func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) *chi.Mux {
r := chi.NewRouter()
// Static assets (embedded in binary)
subFS, _ := fs.Sub(staticFS, "static")
r.Get("/static/*", http.StripPrefix("/static", http.FileServer(http.FS(subFS))).ServeHTTP)
// Serve index.html for SPA routes
serveIndex := func(w http.ResponseWriter, _ *http.Request) {
data, _ := staticFS.ReadFile("static/index.html")
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write(data)
}
// WebSocket endpoint
r.Get("/ws", app.HandleWebSocket)
// API endpoints
r.Get("/api/devices", app.HandleAPIDevices)
r.Get("/api/device/{id}", app.HandleAPIDevice)
r.Post("/api/discover", func(w http.ResponseWriter, r *http.Request) {
app.HandleAPIDiscover(w, r)
// Trigger discovery
//nolint:contextcheck // Context is created within goroutine
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Broadcast discovery start
app.BroadcastDiscoveryStatus("starting", len(app.Devices))
discoverDevices(ctx, app, discoveryService)
// Broadcast discovery completion and updated device list
app.BroadcastDiscoveryStatus("completed", len(app.Devices))
app.BroadcastDeviceList()
}()
})
// Device control endpoints (GET for most actions, POST for volume/bass)
r.Get("/api/control/{id}/{action}", app.HandleAPIControl)
r.Post("/api/control/{id}/{action}", app.HandleAPIControl)
// TuneIn browse, search, and playback
r.Get("/api/tunein/search", app.HandleTuneInSearch)
r.Get("/api/tunein/navigate", app.HandleTuneInNavigate)
r.Get("/api/tunein/navigate/*", app.HandleTuneInNavigate)
r.Post("/api/tunein/play/{id}", app.HandlePlayTuneIn)
// Enhanced device control endpoints
r.Post("/api/device-key/{id}/{key}", app.HandleDeviceKey)
r.Post("/api/device-volume/{id}/{volume}", app.HandleDirectVolumeControl)
r.Post("/api/device-power/{id}", app.HandleDevicePower)
r.Get("/api/device-power-status/{id}", app.HandleDevicePowerStatus)
r.Get("/api/device-ws/{id}", app.HandleDeviceWebSocket)
// SPA routes - serve index.html for client-side routing
r.Get("/", serveIndex)
r.Get("/devices", serveIndex)
r.Get("/device/*", serveIndex)
return r
}
func discoverDevices(ctx context.Context, app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) {
log.Println("Starting device discovery...")
devices, err := discoveryService.DiscoverDevices(ctx)
if err != nil {
log.Printf("Discovery failed: %v", err)
app.BroadcastDiscoveryStatus("failed", len(app.Devices))
return
}
log.Printf("Found %d devices", len(devices))
for _, device := range devices {
deviceID := device.Host // Use host as unique ID for now
// Skip if we already have this device
if _, exists := app.Devices[deviceID]; exists {
app.Devices[deviceID].LastSeen = time.Now()
continue
}
// Create new device connection
clientConfig := &client.Config{
Host: device.Host,
Port: device.Port,
Timeout: 10 * time.Second,
}
soundTouchClient := client.NewClient(clientConfig)
// Get device info
deviceInfo, err := soundTouchClient.GetDeviceInfo()
if err != nil {
log.Printf("Failed to get device info for %s: %v", device.Host, err)
continue
}
// Create device connection
conn := &webtypes.DeviceConnection{
Client: soundTouchClient,
DeviceInfo: deviceInfo,
LastSeen: time.Now(),
Status: webtypes.DeviceStatus{
IsConnected: false,
LastActivity: time.Now(),
},
}
// Initial status fetch asynchronously to avoid blocking discovery
go app.UpdateDeviceStatus(deviceID, conn)
app.Devices[deviceID] = conn
log.Printf("Added device: %s (%s) at %s", deviceInfo.Name, deviceInfo.Type, device.Host)
}
}
+9 -26
View File
@@ -9,9 +9,9 @@ import (
"testing"
"time"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
"github.com/go-chi/chi/v5"
)
@@ -55,22 +55,19 @@ func TestSPARouting(t *testing.T) {
req := httptest.NewRequest("GET", tt.path, nil)
w := httptest.NewRecorder()
// Simulate SPA routing handler
spaHandler := func(w http.ResponseWriter, r *http.Request) {
// If it's an API route, let it pass through
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/static/") || strings.HasPrefix(r.URL.Path, "/ws") {
http.NotFound(w, r)
return
}
// Serve the SPA index.html content (simulated)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>SoundTouch Control Center</title>
<title>SoundTouch Web</title>
</head>
<body>
<div id="app">SPA Content</div>
@@ -100,7 +97,7 @@ func TestSPARouting(t *testing.T) {
}
func TestAPIEndpoints(t *testing.T) {
app := handlers.NewWebApp()
app := soundtouchweb.NewWebApp()
tests := []struct {
name string
@@ -127,7 +124,7 @@ func TestAPIEndpoints(t *testing.T) {
name: "device API with ID",
path: "/api/device/test-device",
method: "GET",
expectedStatus: http.StatusNotFound, // Device won't exist in test
expectedStatus: http.StatusNotFound,
expectedJSON: true,
},
}
@@ -160,7 +157,6 @@ func TestAPIEndpoints(t *testing.T) {
t.Errorf("Expected JSON content type, got %s", contentType)
}
// Validate JSON response structure
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Errorf("Invalid JSON response: %v", err)
@@ -171,7 +167,7 @@ func TestAPIEndpoints(t *testing.T) {
}
func TestAPIResponseFormat(t *testing.T) {
app := handlers.NewWebApp()
app := soundtouchweb.NewWebApp()
req := httptest.NewRequest("GET", "/api/devices", nil)
w := httptest.NewRecorder()
@@ -183,7 +179,6 @@ func TestAPIResponseFormat(t *testing.T) {
t.Fatalf("Failed to decode JSON response: %v", err)
}
// Check API response structure
if !response.Success {
t.Errorf("Expected success=true, got success=%v", response.Success)
}
@@ -192,7 +187,6 @@ func TestAPIResponseFormat(t *testing.T) {
t.Errorf("Expected data field to be present")
}
// Data should be an empty map for no devices
dataMap, ok := response.Data.(map[string]interface{})
if !ok {
t.Errorf("Expected data to be a map, got %T", response.Data)
@@ -204,7 +198,7 @@ func TestAPIResponseFormat(t *testing.T) {
}
func TestControlAPIValidation(t *testing.T) {
app := handlers.NewWebApp()
app := soundtouchweb.NewWebApp()
tests := []struct {
name string
@@ -249,7 +243,6 @@ func TestControlAPIValidation(t *testing.T) {
},
}
// Add a mock device for testing unknown action validation
mockDevice := &webtypes.DeviceConnection{
Client: nil,
DeviceInfo: &models.DeviceInfo{Name: "Test Device"},
@@ -278,7 +271,6 @@ func TestControlAPIValidation(t *testing.T) {
t.Errorf("Test %s: Expected status %d, got %d. Response: %s", tt.name, tt.expectedStatus, w.Code, w.Body.String())
}
// Validate error response format
contentType := w.Header().Get("Content-Type")
if !strings.Contains(contentType, "application/json") {
t.Errorf("Expected JSON content type, got %s", contentType)
@@ -301,9 +293,8 @@ func TestControlAPIValidation(t *testing.T) {
}
func TestWebSocketUpgrade(t *testing.T) {
app := handlers.NewWebApp()
app := soundtouchweb.NewWebApp()
// Test WebSocket upgrade request
req := httptest.NewRequest("GET", "/ws", nil)
req.Header.Set("Connection", "upgrade")
req.Header.Set("Upgrade", "websocket")
@@ -312,16 +303,11 @@ func TestWebSocketUpgrade(t *testing.T) {
w := httptest.NewRecorder()
// The actual WebSocket upgrade will fail in test environment,
// but we can check that the handler exists and accepts the request
app.HandleWebSocket(w, req)
// In a real test environment, this would fail with a websocket upgrade error
// We're just checking the handler doesn't panic and processes the request
}
func TestJSONAPIConsistency(t *testing.T) {
app := handlers.NewWebApp()
app := soundtouchweb.NewWebApp()
endpoints := []string{
"/api/devices",
@@ -344,19 +330,16 @@ func TestJSONAPIConsistency(t *testing.T) {
}
}
// All API endpoints should return JSON
contentType := w.Header().Get("Content-Type")
if !strings.Contains(contentType, "application/json") {
t.Errorf("Endpoint %s should return JSON, got %s", endpoint, contentType)
}
// All responses should follow APIResponse structure
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Errorf("Endpoint %s returned invalid JSON: %v", endpoint, err)
}
// Response should have either data or error
if response.Success && response.Data == nil {
t.Errorf("Endpoint %s: success response should have data", endpoint)
}
File diff suppressed because it is too large Load Diff
-200
View File
@@ -1,200 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SoundTouch Control Center</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css"
rel="stylesheet"
/>
<link href="/static/css/app.css" rel="stylesheet" />
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark">
<div class="container">
<a class="navbar-brand" href="#" onclick="showPage('devices')">
<i class="bi bi-speaker"></i>
SoundTouch Control
</a>
<div class="navbar-nav ms-auto">
<a
class="nav-link"
href="#"
onclick="showPage('devices')"
title="Home"
>
<i class="bi bi-house"></i>
</a>
<a
class="nav-link tunein-nav-link"
href="#"
onclick="showPage('tunein')"
title="TuneIn Browse"
>
<img
src="/static/img/tunein-mono.svg"
alt="TuneIn"
class="tunein-nav-icon"
/>
</a>
<a
class="nav-link"
href="#"
onclick="discoverDevices()"
title="Discover Devices"
>
<i class="bi bi-search"></i>
</a>
<button
class="theme-toggle nav-link"
onclick="toggleTheme()"
title="Toggle Dark Mode"
>
<i id="theme-icon" class="bi bi-moon"></i>
</button>
</div>
</div>
</nav>
<div class="container mt-4">
<!-- Device List Page -->
<div id="devices-page" class="page active">
<div
class="d-flex justify-content-between align-items-center mb-4"
>
<h2>Your SoundTouch Devices</h2>
<button class="btn btn-primary" onclick="discoverDevices()">
<i class="bi bi-search"></i>
Discover Devices
</button>
</div>
<div id="devices-loading" class="loading-spinner"></div>
<div id="devices-list" class="row">
<!-- Device cards will be inserted here by JavaScript -->
</div>
<div
id="no-devices"
style="display: none"
class="text-center py-5"
>
<i class="bi bi-speaker display-1 text-muted"></i>
<h4 class="mt-3">No Devices Found</h4>
<p class="text-muted">
Click "Discover Devices" to search for SoundTouch
speakers on your network.
</p>
<button class="btn btn-primary" onclick="discoverDevices()">
<i class="bi bi-search"></i>
Start Discovery
</button>
</div>
</div>
<!-- TuneIn Browse Page -->
<div id="tunein-page" class="page">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2><img src="/static/img/tunein-dark.svg" alt="TuneIn" class="tunein-heading-icon me-2" />TuneIn Browse</h2>
</div>
<div class="tunein-search-bar mb-3">
<div class="input-group">
<input
type="text"
id="tunein-search-input"
class="form-control"
placeholder="Search stations, podcasts..."
/>
<button
class="btn btn-primary"
onclick="tuneInSearch(document.getElementById('tunein-search-input').value)"
>
<i class="bi bi-search"></i>
Search
</button>
<button
class="btn btn-outline-secondary"
onclick="tuneInBrowse()"
title="Browse top level"
>
<i class="bi bi-house"></i>
</button>
</div>
</div>
<nav id="tunein-breadcrumb" class="mb-3" style="display: none">
<!-- filled by JavaScript -->
</nav>
<div id="tunein-results">
<!-- filled by JavaScript -->
</div>
</div>
<!-- Device Control Page -->
<div id="device-page" class="page">
<div class="back-button">
<button
class="btn btn-outline-secondary"
onclick="showPage('devices')"
>
<i class="bi bi-arrow-left"></i>
Back to Devices
</button>
</div>
<div id="device-content">
<!-- Device control content will be inserted here by JavaScript -->
</div>
</div>
</div>
<footer class="footer">
<div class="container text-center">
<small>
SoundTouch Web Control Interface -
<a
href="https://github.com/gesellix/Bose-SoundTouch"
target="_blank"
class="text-decoration-none"
>
Open Source Project
</a>
</small>
</div>
</footer>
<!-- Toast container for notifications -->
<div class="toast-container"></div>
<!-- Device picker for TuneIn playback -->
<div class="modal fade" id="devicePickerModal" tabindex="-1" aria-labelledby="devicePickerLabel" aria-hidden="true">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="devicePickerLabel">
<i class="bi bi-speaker me-2"></i>Play on device
</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-2" id="devicePickerList">
<!-- device buttons filled by JavaScript -->
</div>
</div>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- Application JavaScript -->
<script src="/static/js/app.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,4 +1,5 @@
accounts/
backend/
certs/
default/
dns/
+2 -2
View File
@@ -18,7 +18,7 @@ services:
- AMAZON_PROFILE_URL=http://amazon-mock:8080/user/profile
spotify-mock:
image: golang:1.26.2-alpine
image: golang:1.26.3-alpine
container_name: spotify-mock
working_dir: /app
volumes:
@@ -30,7 +30,7 @@ services:
- soundtouch-test-net
amazon-mock:
image: golang:1.26.2-alpine
image: golang:1.26.3-alpine
container_name: amazon-mock
working_dir: /app
volumes:
+22 -2
View File
@@ -28,7 +28,27 @@ soundtouch-service
The service starts on port 8000. Open `http://localhost:8000` in your browser.
### Docker (Linux — with host networking for device discovery)
### Docker Compose (recommended for home servers and VMs)
The repository ships a `docker-compose.yml` ready for this use case. Clone or download it, copy the example config, then edit `.env` before starting:
```bash
cp .env.example .env
# Edit .env:
# SOUNDTOUCH_HOSTNAME=192.168.1.100 ← your server's address
# SOUNDTOUCH_VERSION=v0.70.0 ← pin to a release tag instead of 'latest'
docker compose up -d
```
`SOUNDTOUCH_HOSTNAME` is the address your speakers will use to reach the service — use a hostname or IP reachable from the speaker, not `localhost`.
On **Linux** (Debian, Proxmox VE, Raspberry Pi OS, etc.) you can enable host networking for automatic speaker discovery. Uncomment the `network_mode: host` line in `docker-compose.yml` and remove the `ports:` section (they conflict with host networking). Without host networking, add your speakers by IP address in Step 4 instead.
For local overrides (e.g. switching to `build: .` during development), create a `docker-compose.override.yml` — Docker Compose picks it up automatically and it is not tracked in version control.
> **Note on `docker-compose.ci.yml`**: this file contains mock services used only for automated integration tests. It is not needed for your own deployment.
### Docker run (Linux — with host networking for device discovery)
```bash
docker run -d \
@@ -38,7 +58,7 @@ docker run -d \
ghcr.io/gesellix/bose-soundtouch:latest
```
### Docker (macOS / Windows — manual device IP required)
### Docker run (macOS / Windows — manual device IP required)
```bash
docker run -d \
+79
View File
@@ -853,6 +853,85 @@ cat data/accounts/3230304/devices/*/DeviceInfo.xml | grep macAddress
---
## 🌐 **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.168.1.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.168.1.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.168.1.100:8000
HTTPS_SERVER_URL=https://192.168.1.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
+2 -2
View File
@@ -1,8 +1,8 @@
module navigation-station-demo
go 1.26.2
go 1.26.3
require github.com/gesellix/bose-soundtouch v0.57.0
require github.com/gesellix/bose-soundtouch v0.71.2
require github.com/gorilla/websocket v1.5.3 // indirect
+2 -2
View File
@@ -1,8 +1,8 @@
module preset-management-example
go 1.26.2
go 1.26.3
require github.com/gesellix/bose-soundtouch v0.57.0
require github.com/gesellix/bose-soundtouch v0.71.2
require github.com/gorilla/websocket v1.5.3 // indirect
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/gesellix/bose-soundtouch
go 1.26.2
go 1.26.3
require (
github.com/go-chi/chi/v5 v5.2.5
+6 -2
View File
@@ -783,7 +783,11 @@ func (c *Client) SelectSource(source, sourceAccount string) error {
case "BLUETOOTH":
contentItem.ItemName = "Bluetooth"
case "AUX":
contentItem.ItemName = "AUX Input"
contentItem.ItemName = "AUX IN"
// The speaker rejects AUX with empty sourceAccount as INVALID_SOURCE.
if contentItem.SourceAccount == "" {
contentItem.SourceAccount = "AUX"
}
case "TUNEIN":
contentItem.ItemName = "TuneIn"
case "PANDORA":
@@ -818,7 +822,7 @@ func (c *Client) SelectBluetooth() error {
return c.SelectSource("BLUETOOTH", "")
}
// SelectAux is a convenience method to select AUX input
// SelectAux is a convenience method to select AUX input.
func (c *Client) SelectAux() error {
return c.SelectSource("AUX", "")
}
+3 -3
View File
@@ -38,7 +38,7 @@ func TestClient_SelectSource(t *testing.T) {
{
name: "Valid AUX source",
source: "AUX",
sourceAccount: "",
sourceAccount: "AUX",
wantError: false,
},
{
@@ -305,7 +305,7 @@ func TestClient_ConvenienceSourceMethods(t *testing.T) {
method: "aux",
sourceAccount: "",
expectedSource: "AUX",
expectedAccount: "",
expectedAccount: "AUX",
},
{
name: "SelectTuneIn",
@@ -530,7 +530,7 @@ func getExpectedItemName(source string) string {
case "BLUETOOTH":
return "Bluetooth"
case "AUX":
return "AUX Input"
return "AUX IN"
case "TUNEIN":
return "TuneIn"
case "PANDORA":
+9 -3
View File
@@ -17,7 +17,8 @@ import (
// CertificateManager handles CA and certificate generation.
type CertificateManager struct {
CertsDir string
CertsDir string
CommonName string // CN for generated server certs; defaults to "localhost" if empty
}
// NewCertificateManager creates a new CertificateManager.
@@ -137,7 +138,7 @@ func (cm *CertificateManager) GetServerTLSConfig(domains []string) (*tls.Config,
// GenerateCA generates a new CA certificate and key.
func (cm *CertificateManager) GenerateCA() error {
priv, err := rsa.GenerateKey(rand.Reader, 4096)
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return err
}
@@ -255,11 +256,16 @@ func (cm *CertificateManager) GenerateCertificate(domains []string) ([]byte, []b
}
}
cn := cm.CommonName
if cn == "" {
cn = "localhost"
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"AfterTouch"},
CommonName: domains[0],
CommonName: cn,
},
NotBefore: notBefore,
NotAfter: notAfter,
+3 -2
View File
@@ -16,6 +16,7 @@ func TestCertificateManager(t *testing.T) {
defer os.RemoveAll(tempDir)
cm := NewCertificateManager(filepath.Join(tempDir, "certs"))
cm.CommonName = "test.local"
// Test CA generation
if err := cm.EnsureCA(); err != nil {
@@ -67,8 +68,8 @@ func TestCertificateManager(t *testing.T) {
t.Fatalf("Failed to parse certificate: %v", err)
}
if cert.Subject.CommonName != domains[0] {
t.Errorf("Expected CommonName %s, got %s", domains[0], cert.Subject.CommonName)
if cert.Subject.CommonName != cm.CommonName {
t.Errorf("Expected CommonName %s, got %s", cm.CommonName, cert.Subject.CommonName)
}
// Check DNS names
+1 -1
View File
@@ -309,7 +309,7 @@ func TestMargeAccountFullExcludesEmptyAmazonSource(t *testing.T) {
t.Fatalf("Failed to write first device DeviceInfo.xml: %v", err)
}
if err := os.WriteFile(filepath.Join(firstDir, "Sources.xml"), []byte(`<sources>
<source id="10006" type="Audio" createdOn="2026-01-01T00:00:00.000+00:00" updatedOn="2026-01-01T00:00:00.000+00:00" displayName="Amazon Music" secret="" secretType="token" sourceproviderid="20">
<source id="10006" type="AMAZON" createdOn="2026-01-01T00:00:00.000+00:00" updatedOn="2026-01-01T00:00:00.000+00:00" displayName="Amazon Music" secret="" secretType="token" sourceproviderid="20">
<sourceKey type="AMAZON" account=""/>
</source>
</sources>`), 0644); err != nil {
+4 -4
View File
@@ -578,7 +578,7 @@ func (s *Server) bridgeAmazonToMarge(accountID string) {
}
for _, acc := range accounts {
log.Printf("[Amazon Bridge] Registering Amazon user %s in Marge for account %s", acc.UserID, accountID)
log.Printf("[Amazon Bridge] Registering Amazon user %s in Marge for account %s", acc.Email, accountID)
// Build the AmazonSecret credential envelope expected by the speaker firmware.
credMap := map[string]interface{}{
@@ -594,7 +594,7 @@ func (s *Server) bridgeAmazonToMarge(accountID string) {
continue
}
_, err = marge.AddSource(s.ds, accountID, acc.UserID, strconv.Itoa(constants.AmazonProviderID), string(credJSON), constants.CredentialTypeToken, acc.DisplayName)
_, err = marge.AddSource(s.ds, accountID, acc.Email, strconv.Itoa(constants.AmazonProviderID), string(credJSON), constants.CredentialTypeToken, acc.DisplayName)
if err != nil {
log.Printf("[Amazon Bridge] Failed to register source in Marge: %v", err)
continue
@@ -623,7 +623,7 @@ func (s *Server) bridgeAmazonToMarge(accountID string) {
cfg.Host = d.IPAddress
cfg.Timeout = 5 * time.Second
c := client.NewClient(cfg)
creds := models.NewAmazonOAuthCredentials(acc.UserID, string(credJSON), acc.DisplayName)
creds := models.NewAmazonOAuthCredentials(acc.Email, string(credJSON), acc.DisplayName)
if err := c.SetMusicServiceOAuthAccount(creds); err != nil {
log.Printf("[Amazon Bridge] Failed to notify speaker %s via OAuth: %v", d.Name, err)
@@ -633,7 +633,7 @@ func (s *Server) bridgeAmazonToMarge(accountID string) {
log.Printf("[Amazon Bridge] Sync notification failed for speaker %s: %v", d.Name, err)
log.Printf("[Amazon Bridge] Falling back to legacy account creation for speaker %s", d.Name)
legacyCreds := models.NewAmazonMusicCredentials(acc.UserID, string(credJSON))
legacyCreds := models.NewAmazonMusicCredentials(acc.Email, string(credJSON))
if err := c.SetMusicServiceAccount(legacyCreds); err != nil {
log.Printf("[Amazon Bridge] Legacy fallback failed for speaker %s: %v", d.Name, err)
} else {
+5 -1
View File
@@ -256,11 +256,15 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
s.serverURL = settings.ServerURL
s.discoveryEnabled = settings.DiscoveryEnabled
if settings.DiscoveryInterval != "" {
s.discoveryInterval = interval
}
s.discoveryEnabled = settings.DiscoveryEnabled
if s.discoveryInterval == 0 {
s.discoveryEnabled = false
}
s.dnsEnabled = settings.DNSEnabled
// Handle comma-separated upstream DNS servers
+33
View File
@@ -333,6 +333,20 @@
</div>
</div>
<div style="margin-bottom: 20px">
<strong>Root CA Certificate:</strong>
<div style="margin-top: 8px; font-size: 0.9em; color: #555; max-width: 600px">
Import this certificate into your OS or browser trust store to
trust HTTPS connections to this AfterTouch server from other
clients (e.g. curl, Python scripts, browsers).
</div>
<div style="margin-top: 8px">
<a href="/setup/ca.crt" download="soundtouch-ca.crt">
<button type="button">Download CA Certificate</button>
</a>
</div>
</div>
<div style="margin-bottom: 20px">
<button onclick="updateSettings()">Save Settings</button>
<span
@@ -531,6 +545,12 @@
>
Trust CA Now
</button>
<a
href="/setup/ca.crt"
download="soundtouch-ca.crt"
style="margin-left: 10px; font-size: 0.85em"
title="Download CA cert to import into other clients"
>Download CA cert</a>
</p>
<div
@@ -854,6 +874,19 @@
>Planned Config (AfterTouch)</span
>
<pre id="planned-config"></pre>
<div
id="resolve-ip-error"
style="display: none; margin-top: 10px; padding: 10px; background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; color: #856404;"
>
⚠️ <strong>Hostname resolution warning:</strong>
<span id="resolve-ip-error-msg"></span>
<br/>
The planned IP shown above may be incorrect.
Migration methods that write IPs to the device
(hosts, resolv.conf) will refuse to proceed until
the hostname can be resolved from the device itself.
<a href="https://github.com/gesellix/bose-soundtouch/blob/main/docs/guides/TROUBLESHOOTING.md#hostname-resolution" target="_blank" style="color: #856404;">Learn more →</a>
</div>
</div>
<div
id="planned-hosts-pane"
+8
View File
@@ -1816,6 +1816,14 @@ async function showSummary(deviceId) {
document.getElementById("planned-hosts").innerText = summary.planned_hosts || "";
document.getElementById("planned-resolv").innerText = summary.planned_resolv || "";
const resolveErrEl = document.getElementById("resolve-ip-error");
if (summary.resolve_ip_error) {
document.getElementById("resolve-ip-error-msg").innerText = summary.resolve_ip_error;
resolveErrEl.style.display = "block";
} else {
resolveErrEl.style.display = "none";
}
const currentResolvElem = document.getElementById("current-resolv-content");
if (currentResolvElem) {
currentResolvElem.innerText = summary.current_resolv_conf || "Not available";
+5 -1
View File
@@ -106,7 +106,11 @@ func ensureTimestamps(s *models.ConfiguredSource) {
func ensureSourceType(s *models.ConfiguredSource) {
if s.Type == "" || (s.SourceKey.Type != "" && s.SourceKey.Type != constants.ProviderAux && s.SourceKey.Type != constants.ProviderBluetooth) {
s.Type = "Audio"
if s.SourceKey.Type == constants.ProviderAmazon {
s.Type = constants.ProviderAmazon
} else {
s.Type = "Audio"
}
}
}
+107 -65
View File
@@ -72,6 +72,7 @@ type MigrationSummary struct {
CurrentResolvConf string `json:"current_resolv_conf,omitempty"`
PlannedResolv string `json:"planned_resolv,omitempty"`
IsMigrated bool `json:"is_migrated"`
ResolveIPError string `json:"resolve_ip_error,omitempty"`
MirrorEnabled bool `json:"mirror_enabled"`
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
SkipMirrorEndpoints []string `json:"skip_mirror_endpoints,omitempty"`
@@ -257,40 +258,8 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
summary.PlannedConfig = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" + string(xmlContent)
// 2b. Initial planned hosts config
parsedURL, err := url.Parse(targetURL)
if err == nil {
hostName := parsedURL.Hostname()
if hostName != "" && hostName != "localhost" {
client := m.NewSSH(deviceIP)
hostIP := m.resolveIP(hostName, client)
// Predicted aftertouch.resolv.conf
summary.PlannedResolv = fmt.Sprintf("# Created by Aftertouch/SoundTouch-Service\n# Priority nameserver for Bose service redirection\nnameserver %s\n", hostIP)
domains := []string{
"streaming.bose.com",
"updates.bose.com",
"stats.bose.com",
"bmx.bose.com",
"content.api.bose.io",
"events.api.bosecm.com",
"bose-prod.apigee.net",
"worldwide.bose.com",
"music.api.bose.com",
"media.bose.io",
"downloads.bose.com",
"voice.api.bose.io",
}
var hostsLines []string
for _, domain := range domains {
hostsLines = append(hostsLines, fmt.Sprintf("%s\t%s", hostIP, domain))
}
summary.PlannedHosts = strings.Join(hostsLines, "\n")
}
}
// 2b. Planned network config (hosts entries, resolv.conf preview, resolve error)
m.populatePlannedNetworkConfig(summary, deviceIP, targetURL)
// 3. Check for remote services files
m.checkRemoteServices(summary, deviceIP)
@@ -307,18 +276,7 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
}
// 5. Provide HTTPS URL for testing
if parsedURL, err := url.Parse(targetURL); err == nil {
hostIP := parsedURL.Hostname()
if hostIP != "" {
// Find HTTPS port from environment or default
httpsPort := os.Getenv("HTTPS_PORT")
if httpsPort == "" {
httpsPort = "8443"
}
summary.ServerHTTPSURL = fmt.Sprintf("https://%s:%s/health", hostIP, httpsPort)
}
}
summary.ServerHTTPSURL = m.buildServerHTTPSURL(targetURL)
// 6. Check if migrated
m.checkIsMigrated(summary, deviceIP)
@@ -337,6 +295,67 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
return summary, nil
}
func (m *Manager) populatePlannedNetworkConfig(summary *MigrationSummary, deviceIP, targetURL string) {
parsedURL, err := url.Parse(targetURL)
if err != nil {
return
}
hostName := parsedURL.Hostname()
if hostName == "" || hostName == "localhost" {
return
}
client := m.NewSSH(deviceIP)
hostIP, resolveErr := m.resolveIP(hostName, client)
if resolveErr != nil {
summary.ResolveIPError = resolveErr.Error()
}
if hostIP == "" {
hostIP = hostName
}
summary.PlannedResolv = fmt.Sprintf("# Created by Aftertouch/SoundTouch-Service\n# Priority nameserver for Bose service redirection\nnameserver %s\n", hostIP)
domains := []string{
"streaming.bose.com",
"updates.bose.com",
"stats.bose.com",
"bmx.bose.com",
"content.api.bose.io",
"events.api.bosecm.com",
"bose-prod.apigee.net",
"worldwide.bose.com",
"music.api.bose.com",
"media.bose.io",
"downloads.bose.com",
"voice.api.bose.io",
}
hostsLines := make([]string, len(domains))
for i, domain := range domains {
hostsLines[i] = fmt.Sprintf("%s\t%s", hostIP, domain)
}
summary.PlannedHosts = strings.Join(hostsLines, "\n")
}
func (m *Manager) buildServerHTTPSURL(targetURL string) string {
parsedURL, err := url.Parse(targetURL)
if err != nil || parsedURL.Hostname() == "" {
return ""
}
httpsPort := os.Getenv("HTTPS_PORT")
if httpsPort == "" {
httpsPort = "8443"
}
return fmt.Sprintf("https://%s:%s/health", parsedURL.Hostname(), httpsPort)
}
// checkIsMigrated determines if the device is already migrated to AfterTouch.
func (m *Manager) checkIsMigrated(summary *MigrationSummary, deviceIP string) {
if !summary.SSHSuccess {
@@ -418,7 +437,7 @@ func (m *Manager) isResolvConfMigrated(client SSHClient, summary *MigrationSumma
return true
}
resolvedIP := m.resolveIP(targetHost, client)
resolvedIP, _ := m.resolveIP(targetHost, client)
if resolvedIP != "" && strings.Contains(summary.CurrentResolvConf, resolvedIP) && summary.CACertTrusted {
return true
}
@@ -765,11 +784,11 @@ func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options ma
if backupOut, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err != nil {
logs += fmt.Sprintf("Backing up original config to %s.original (check: %s)\n", remotePath, backupOut)
fmt.Printf("Backing up original config to %s.original\n", remotePath)
// Try to copy existing config to .original, ensuring filesystem is writable
if output, err := client.Run(fmt.Sprintf("%s && cp %s %s.original", rwCmd, remotePath, remotePath)); err != nil {
logs += fmt.Sprintf("cp backup failed: %v (output: %s)\n", err, output)
fmt.Printf("cp backup failed: %v (output: %s)\n", err, output)
// Fallback to manual upload if cp failed (might not have cp?)
if config, err := client.Run(fmt.Sprintf("cat %s", remotePath)); err == nil && config != "" {
if err := client.UploadContent([]byte(config), remotePath+".original"); err != nil {
logs += "failed to upload backup config: " + err.Error() + "\n"
@@ -787,10 +806,7 @@ func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options ma
logs += "Backup .original already exists\n"
}
// 1. Upload the configuration (rw is handled by calling it before if needed, but UploadContent uses cat > which needs rw)
// We'll wrap the upload in a way that EnsureRemoteServices and others might benefit,
// but UploadContent is a separate method. We should probably add rw to UploadContent or call it before.
// Actually, let's call rw before UploadContent here.
// 1. Upload the configuration
out, _ = client.Run(rwCmd)
logs += rwCmd + ": " + out + "\n"
@@ -1062,7 +1078,11 @@ func (m *Manager) migrateViaHosts(deviceIP, targetURL string) (string, error) {
return "", fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName)
}
hostIP := m.resolveIP(hostName, client)
hostIP, err := m.resolveIP(hostName, client)
if err != nil {
return logs, fmt.Errorf("cannot resolve target hostname for migration: %w", err)
}
logs += fmt.Sprintf("Resolved %s to %s\n", hostName, hostIP)
// 2. Prepare /etc/hosts entries
@@ -1218,7 +1238,11 @@ func (m *Manager) migrateViaResolvConf(deviceIP, targetURL string) (string, erro
return "", fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName)
}
hostIP := m.resolveIP(hostName, client)
hostIP, err := m.resolveIP(hostName, client)
if err != nil {
return logs, fmt.Errorf("cannot resolve target hostname for migration: %w", err)
}
logs += fmt.Sprintf("Resolved %s to %s\n", hostName, hostIP)
// 2. Prepare /mnt/nv/soundtouch-service/aftertouch.resolv.conf content
@@ -1888,7 +1912,12 @@ func (m *Manager) parseTargetURLAndResolveIP(targetURL string, client SSHClient)
return "", nil, fmt.Errorf("target URL must contain a valid IP or hostname (got %s)", hostName)
}
return m.resolveIP(hostName, client), parsedURL, nil
hostIP, err := m.resolveIP(hostName, client)
if err != nil {
return "", nil, fmt.Errorf("cannot resolve target hostname: %w", err)
}
return hostIP, parsedURL, nil
}
func (m *Manager) addTemporaryHostEntry(client SSHClient, deviceIP, testDomain, testEntry, rwCmd string) error {
@@ -2035,15 +2064,21 @@ func (m *Manager) TestConnection(deviceIP, targetURL string, useExplicitCA bool)
// GetResolvedIP returns the resolved IP for a hostname, attempting to resolve it from any connected device first.
func (m *Manager) GetResolvedIP(host string) string {
return m.resolveIP(host, nil)
ip, _ := m.resolveIP(host, nil)
return ip
}
func (m *Manager) resolveIP(host string, client SSHClient) string {
// resolveIP resolves a hostname to an IP address.
// It first tries to resolve from the device via SSH ping (authoritative for migration).
// If that fails, it falls back to resolving from the service itself.
// An error is returned whenever the SSH ping did not produce the IP, so callers that
// write config to the device can abort rather than risk writing an unresolvable hostname.
func (m *Manager) resolveIP(host string, client SSHClient) (string, error) {
if net.ParseIP(host) != nil {
return host
return host, nil
}
// 1. Try resolving FROM the device via SSH (best for containers/NAT)
// 1. Try resolving FROM the device via SSH (authoritative: gives the IP the device will actually use)
if client != nil {
// Use ping to resolve hostname on the device.
// Busybox ping output usually looks like: PING host (1.2.3.4): 56 data bytes
@@ -2057,26 +2092,33 @@ func (m *Manager) resolveIP(host string, client SSHClient) string {
ip := output[start+1 : end]
if net.ParseIP(ip) != nil {
fmt.Printf("Resolved %s to %s from device\n", host, ip)
return ip
return ip, nil
}
}
}
}
// 2. Fallback: resolve FROM the service itself
// 2. Fallback: resolve FROM the service itself (unreliable for migration — NAT/split-DNS may differ)
ips, err := net.LookupIP(host)
if err != nil || len(ips) == 0 {
return host // Fallback to host if resolution fails
return "", fmt.Errorf("cannot resolve %q: SSH ping from device failed and service-side DNS lookup also failed", host)
}
// Prefer IPv4
var resolved string
for _, ip := range ips {
if ip.To4() != nil {
return ip.String()
resolved = ip.String()
break
}
}
return ips[0].String()
if resolved == "" {
resolved = ips[0].String()
}
return resolved, fmt.Errorf("resolved %q to %s from service, not from device — result may be wrong if NAT or split-DNS is in use", host, resolved)
}
// SyncDeviceData fetches presets, recents and sources from the device and saves them to the datastore.
+56 -12
View File
@@ -597,17 +597,22 @@ func TestTestHostsRedirection(t *testing.T) {
func TestResolveIP(t *testing.T) {
m := &Manager{}
// Test with IP
if m.resolveIP("1.2.3.4", nil) != "1.2.3.4" {
t.Errorf("Expected 1.2.3.4, got %s", m.resolveIP("1.2.3.4", nil))
// IP passthrough: no resolution needed, no error
ip, err := m.resolveIP("1.2.3.4", nil)
if ip != "1.2.3.4" || err != nil {
t.Errorf("Expected 1.2.3.4/nil, got %s/%v", ip, err)
}
// Test with localhost
if m.resolveIP("localhost", nil) != "127.0.0.1" && m.resolveIP("localhost", nil) != "::1" {
t.Errorf("Expected localhost resolution, got %s", m.resolveIP("localhost", nil))
// localhost resolves from service DNS; error expected (no SSH client)
ip, err = m.resolveIP("localhost", nil)
if ip != "127.0.0.1" && ip != "::1" {
t.Errorf("Expected localhost resolution, got %s", ip)
}
if err == nil {
t.Errorf("Expected error for service-side fallback, got nil")
}
// Test with device resolution (mocked)
// Device SSH ping succeeds: IP returned, no error
mock := &mockSSH{
runFunc: func(command string) (string, error) {
if strings.Contains(command, "ping -c 1 myhost") {
@@ -616,13 +621,18 @@ func TestResolveIP(t *testing.T) {
return "", nil
},
}
if m.resolveIP("myhost", mock) != "10.0.0.5" {
t.Errorf("Expected 10.0.0.5 from device, got %s", m.resolveIP("myhost", mock))
ip, err = m.resolveIP("myhost", mock)
if ip != "10.0.0.5" || err != nil {
t.Errorf("Expected 10.0.0.5/nil from device, got %s/%v", ip, err)
}
// Test with non-existent host (should fallback to input)
if m.resolveIP("non-existent.host.fake", nil) != "non-existent.host.fake" {
t.Errorf("Expected fallback to input, got %s", m.resolveIP("non-existent.host.fake", nil))
// Non-existent host, no SSH client: both methods fail, error returned
ip, err = m.resolveIP("non-existent.host.fake", nil)
if ip != "" {
t.Errorf("Expected empty IP on failure, got %s", ip)
}
if err == nil {
t.Errorf("Expected error for unresolvable host, got nil")
}
}
@@ -1525,6 +1535,40 @@ func TestCheckIsMigrated(t *testing.T) {
})
}
// TestCheckCurrentConfig_ReadsOriginalPath verifies that checkCurrentConfig reads
// from SoundTouchSdkPrivateCfgPath on an unmigrated device (issue #214 regression test).
func TestCheckCurrentConfig_ReadsOriginalPath(t *testing.T) {
m := NewManager("http://aftertouch:8000", nil, nil)
originalCfg := "<SoundTouchSdkPrivateCfg><margeServerUrl>http://streaming.bose.com</margeServerUrl></SoundTouchSdkPrivateCfg>"
m.NewSSH = func(host string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
if strings.HasPrefix(command, "[ -f ") && strings.Contains(command, ".original") {
return "", fmt.Errorf("exit status 1")
}
if command == fmt.Sprintf("cat %s", SoundTouchSdkPrivateCfgPath) {
return originalCfg, nil
}
return "", nil
},
}
}
summary := &MigrationSummary{}
cfg, err := m.checkCurrentConfig(summary, "127.0.0.1")
if err != nil {
t.Fatalf("checkCurrentConfig returned unexpected error: %v", err)
}
if cfg != originalCfg {
t.Errorf("Expected current config to be the original SoundTouchSdkPrivateCfg.xml, got %q", cfg)
}
if !summary.SSHSuccess {
t.Errorf("Expected SSHSuccess to be true when original config is readable")
}
}
func TestMigrateSpeaker_ResolvBlocking(t *testing.T) {
tempDir, err := os.MkdirTemp("", "setup-test")
if err != nil {
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,4 @@
// Package handlers contains tests for HTTP handlers.
package handlers
package soundtouchweb
import (
"context"
@@ -10,9 +9,9 @@ import (
"testing"
"time"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
"github.com/go-chi/chi/v5"
)
@@ -0,0 +1,465 @@
/* ── Reset & Base ─────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f5f5f5;
--surface: #ffffff;
--border: #e0e0e0;
--text: #1a1a1a;
--text-dim: #666;
--accent: #000000;
--accent-fg: #ffffff;
--online: #22c55e;
--offline: #9ca3af;
--radius: 8px;
--shadow: 0 1px 3px rgba(0,0,0,.10), 0 1px 2px rgba(0,0,0,.06);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111;
--surface: #1e1e1e;
--border: #333;
--text: #f0f0f0;
--text-dim: #aaa;
--accent: #e0e0e0;
--accent-fg:#111;
}
}
html, body { height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 15px;
line-height: 1.5;
background: var(--bg);
color: var(--text);
}
a { color: inherit; text-decoration: none; cursor: pointer; }
button { cursor: pointer; font: inherit; border: none; background: none; }
ul { list-style: none; }
img { display: block; max-width: 100%; }
/* ── Layout ──────────────────────────────────────────────────────────────── */
.app { display: flex; flex-direction: column; min-height: 100vh; }
/* ── Navbar ──────────────────────────────────────────────────────────────── */
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.25rem;
height: 52px;
background: var(--accent);
color: var(--accent-fg);
}
.brand { font-size: 1.1rem; font-weight: 600; letter-spacing: .02em; }
.nav-links { display: flex; align-items: center; gap: .75rem; }
.nav-links a, .nav-links .btn-icon {
color: var(--accent-fg);
opacity: .75;
font-size: .9rem;
padding: .25rem .5rem;
border-radius: 4px;
transition: opacity .15s;
}
.nav-links a:hover, .nav-links .btn-icon:hover, .nav-links a.active { opacity: 1; }
.nav-tunein-icon { height: 18px; display: inline-block; filter: brightness(0) invert(1); opacity: .75; }
.nav-links a:hover .nav-tunein-icon, .nav-links a.active .nav-tunein-icon { opacity: 1; }
/* ── Main content ─────────────────────────────────────────────────────────── */
.main-content { flex: 1; padding: 1.5rem 1.25rem; max-width: 960px; width: 100%; margin: 0 auto; }
/* ── Page header ──────────────────────────────────────────────────────────── */
.page-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.page-header h2 { font-size: 1.4rem; font-weight: 600; flex: 1; }
/* ── Buttons ─────────────────────────────────────────────────────────────── */
.btn-primary {
background: var(--accent);
color: var(--accent-fg);
padding: .45rem 1rem;
border-radius: var(--radius);
font-size: .875rem;
font-weight: 500;
transition: opacity .15s;
}
.btn-primary:hover { opacity: .85; }
.btn-secondary {
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
padding: .45rem 1rem;
border-radius: var(--radius);
font-size: .875rem;
transition: background .15s;
}
.btn-secondary:hover { background: var(--bg); }
.btn-icon {
color: inherit;
font-size: 1.1rem;
padding: .25rem .4rem;
border-radius: 4px;
line-height: 1;
transition: opacity .15s;
}
.btn-icon:hover { opacity: .7; }
.back-btn {
color: var(--text-dim);
font-size: .875rem;
padding: .3rem .6rem;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--surface);
}
.back-btn:hover { background: var(--bg); }
/* ── Device grid ─────────────────────────────────────────────────────────── */
.device-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
}
.device-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
cursor: pointer;
transition: box-shadow .15s, transform .1s;
box-shadow: var(--shadow);
}
.device-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.12); transform: translateY(-1px); }
.device-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: .25rem; }
.device-name { font-weight: 600; font-size: .95rem; }
.device-type { font-size: .8rem; color: var(--text-dim); margin-bottom: .5rem; }
.device-indicator {
width: 8px; height: 8px; border-radius: 50%;
flex-shrink: 0;
}
.device-indicator.online { background: var(--online); }
.device-indicator.offline { background: var(--offline); }
.now-playing-mini { font-size: .8rem; color: var(--text-dim); margin-top: .25rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.play-status { margin-right: .3rem; }
.standby-label { font-size: .8rem; color: var(--text-dim); margin-top: .25rem; }
/* ── Device detail ───────────────────────────────────────────────────────── */
.device-detail { max-width: 560px; }
/* ── Now playing ─────────────────────────────────────────────────────────── */
.now-playing {
display: flex;
gap: 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
margin: 1rem 0;
box-shadow: var(--shadow);
min-height: 80px;
align-items: center;
}
.now-playing.standby { color: var(--text-dim); font-size: .9rem; }
.album-art { width: 64px; height: 64px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
.track-info { flex: 1; overflow: hidden; }
.track-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.track-artist { font-size: .875rem; color: var(--text-dim); margin-top: .15rem; }
.track-album { font-size: .8rem; color: var(--text-dim); }
.track-meta { display: flex; align-items: center; gap: .5rem; margin-top: .25rem; }
.track-source { font-size: .75rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; }
.buffering-badge { font-size: .7rem; color: var(--text-dim); background: var(--bg); border-radius: 4px; padding: .1rem .35rem; }
/* ── Transport controls ──────────────────────────────────────────────────── */
.controls {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
box-shadow: var(--shadow);
}
.transport { display: flex; align-items: center; gap: .5rem; margin-bottom: .75rem; }
.ctrl-btn {
font-size: 1.25rem;
padding: .4rem .7rem;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
transition: background .12s;
}
.ctrl-btn:hover { background: var(--bg); }
.ctrl-btn.play-btn { font-size: 1.5rem; padding: .4rem .9rem; }
.ctrl-btn.active { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
.volume-row { display: flex; align-items: center; gap: .75rem; }
.volume-icon { font-size: 1rem; }
.volume-slider { flex: 1; accent-color: var(--accent); }
.volume-value { font-size: .8rem; color: var(--text-dim); min-width: 2.5ch; text-align: right; }
.bass-row { display: flex; align-items: center; gap: .75rem; margin-top: .5rem; }
.bass-label { font-size: .8rem; color: var(--text-dim); width: 2.5ch; }
/* ── Progress bar ────────────────────────────────────────────────────────── */
.progress-row { margin-top: .35rem; display: flex; align-items: center; gap: .5rem; }
.progress-bar {
flex: 1;
height: 3px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width .9s linear;
}
.progress-time { font-size: .7rem; color: var(--text-dim); white-space: nowrap; flex-shrink: 0; }
/* ── Presets ─────────────────────────────────────────────────────────────── */
.presets-section, .sources-section { margin-top: 1.25rem; }
.section-title { font-size: .8rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-dim); margin-bottom: .6rem; }
.preset-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: .4rem;
}
.preset-slot {
display: flex;
flex-direction: column;
align-items: center;
gap: .25rem;
padding: .4rem .2rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
cursor: pointer;
position: relative;
transition: background .1s, box-shadow .1s;
min-height: 72px;
}
.preset-slot:hover:not(:disabled) { background: var(--bg); box-shadow: var(--shadow); }
.preset-slot:disabled { opacity: .4; cursor: default; }
.preset-slot.active { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
.preset-slot.active .preset-name { color: var(--accent-fg); }
.preset-art { width: 36px; height: 36px; border-radius: 4px; object-fit: cover; }
.preset-source-label { font-size: .6rem; font-weight: 600; text-transform: uppercase; opacity: .6; }
.preset-name { font-size: .65rem; text-align: center; line-height: 1.2; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; word-break: break-word; color: var(--text-dim); }
.preset-num {
position: absolute; top: 2px; right: 4px;
font-size: .6rem; font-weight: 700; color: var(--text-dim); opacity: .5;
}
/* ── Sources ─────────────────────────────────────────────────────────────── */
.source-list { display: flex; flex-wrap: wrap; gap: .4rem; }
.source-btn {
display: flex;
align-items: center;
gap: .35rem;
padding: .35rem .7rem;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
font-size: .8rem;
transition: background .1s, border-color .1s;
}
.source-btn:hover { background: var(--bg); }
.source-btn.active { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
.source-btn.local { border-style: dashed; }
.source-icon { font-size: .9rem; line-height: 1; }
.source-name { font-weight: 500; }
/* ── Zone ────────────────────────────────────────────────────────────────── */
.zone-section { margin-top: 1.25rem; }
.zone-row { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
.zone-status-label { font-size: .875rem; color: var(--text-dim); }
.zone-members { display: flex; flex-direction: column; gap: .3rem; }
.zone-member {
display: flex; align-items: center; gap: .6rem;
padding: .4rem .6rem;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius);
}
.zone-master-row { background: var(--bg); }
.zone-badge {
font-size: .65rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .05em; padding: .15rem .4rem; border-radius: 3px; flex-shrink: 0;
}
.zone-badge.master { background: var(--accent); color: var(--accent-fg); }
.zone-badge.slave { background: var(--border); color: var(--text-dim); }
.zone-member-name { flex: 1; font-size: .875rem; }
.zone-remove { font-size: .75rem; color: var(--text-dim); padding: .15rem .35rem; }
.zone-remove:hover { color: var(--text); }
.zone-actions { display: flex; gap: .5rem; margin-top: .25rem; flex-wrap: wrap; }
.zone-btn { font-size: .8rem; padding: .3rem .7rem; }
/* ── Recents ─────────────────────────────────────────────────────────────── */
.recents-section { margin-top: 1.25rem; }
.recents-list { display: flex; flex-direction: column; gap: 1px; }
.recent-item {
display: flex;
align-items: center;
gap: .75rem;
width: 100%;
padding: .5rem .6rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
text-align: left;
color: var(--text);
transition: background .1s;
}
.recent-item:hover { background: var(--bg); }
.recent-art {
width: 40px; height: 40px; border-radius: 4px;
object-fit: cover; flex-shrink: 0;
}
.recent-art-empty {
display: flex; align-items: center; justify-content: center;
background: var(--bg); font-size: 1.1rem;
}
.recent-info { flex: 1; overflow: hidden; }
.recent-name { display: block; font-size: .875rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.recent-source { display: block; font-size: .75rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; margin-top: .1rem; }
.recent-play { color: var(--text-dim); font-size: .75rem; flex-shrink: 0; opacity: .5; }
.recent-item:hover .recent-play { opacity: 1; }
/* ── TuneIn ──────────────────────────────────────────────────────────────── */
.tunein-toolbar { display: flex; gap: .5rem; margin-bottom: 1rem; }
.tunein-search-input {
flex: 1;
padding: .45rem .75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
font: inherit;
font-size: .875rem;
}
.tunein-search-input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.breadcrumb { display: flex; align-items: center; gap: .4rem; margin-bottom: .75rem; font-size: .85rem; flex-wrap: wrap; }
.breadcrumb-sep { color: var(--text-dim); }
.breadcrumb-link { color: var(--text-dim); cursor: pointer; }
.breadcrumb-link:hover { text-decoration: underline; }
.breadcrumb-current { font-weight: 500; }
.loading-bar {
height: 2px;
background: linear-gradient(90deg, var(--accent) 0%, transparent 100%);
border-radius: 1px;
margin-bottom: 1rem;
animation: loading 1s ease-in-out infinite;
}
@keyframes loading { 0%,100% { opacity: .4; } 50% { opacity: 1; } }
.tunein-list { display: flex; flex-direction: column; gap: 1px; }
.tunein-item {
display: flex;
align-items: center;
gap: .75rem;
padding: .6rem .75rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: background .1s;
}
.tunein-item:hover { background: var(--bg); }
.tunein-thumb { width: 40px; height: 40px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
.tunein-item-info { flex: 1; overflow: hidden; }
.tunein-item-name { display: block; font-size: .9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tunein-item-desc { display: block; font-size: .75rem; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tunein-item-arrow { color: var(--text-dim); font-size: .9rem; flex-shrink: 0; }
/* ── Device picker overlay ───────────────────────────────────────────────── */
.overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,.45);
display: flex; align-items: center; justify-content: center;
z-index: 100;
}
.device-picker {
background: var(--surface);
border-radius: var(--radius);
padding: 1.5rem;
min-width: 240px;
max-width: 320px;
box-shadow: 0 8px 32px rgba(0,0,0,.2);
}
.picker-title { font-weight: 600; margin-bottom: .25rem; }
.picker-item-name { font-size: .875rem; color: var(--text-dim); margin-bottom: 1rem; }
.picker-devices { display: flex; flex-direction: column; gap: .5rem; margin-bottom: 1rem; }
.picker-device-btn {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: .6rem 1rem;
text-align: left;
font-size: .9rem;
transition: background .1s;
}
.picker-device-btn:hover { background: var(--border); }
.picker-cancel { width: 100%; }
.picker-no-devices { font-size: .875rem; color: var(--text-dim); text-align: center; padding: .5rem 0; }
/* ── Empty state ─────────────────────────────────────────────────────────── */
.empty-state { text-align: center; padding: 4rem 1rem; color: var(--text-dim); }
.empty-icon { font-size: 3rem; margin-bottom: 1rem; opacity: .4; }
.empty-state p { margin-bottom: 1.5rem; }
/* ── Toast ───────────────────────────────────────────────────────────────── */
.toast {
position: fixed;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
background: var(--accent);
color: var(--accent-fg);
padding: .6rem 1.25rem;
border-radius: 999px;
font-size: .875rem;
box-shadow: 0 4px 12px rgba(0,0,0,.2);
z-index: 200;
pointer-events: none;
animation: fade-in .2s ease;
}
@keyframes fade-in { from { opacity: 0; transform: translateX(-50%) translateY(8px); } }
Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

@@ -0,0 +1,9 @@
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<!-- Morse 'S' (drei Punkte) -->
<circle cx="8" cy="10" r="3" fill="#0055aa"/>
<circle cx="16" cy="10" r="3" fill="#0055aa"/>
<circle cx="24" cy="10" r="3" fill="#0055aa"/>
<!-- Morse 'T' (ein langer Strich) -->
<rect x="5" y="18" width="22" height="6" rx="2" fill="#ffcc00"/>
</svg>

After

Width:  |  Height:  |  Size: 381 B

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SoundTouch Web</title>
<script type="importmap">
{
"imports": {
"preact": "/static/vendor/preact.module.js",
"preact/hooks": "/static/vendor/preact-hooks.module.js",
"htm": "/static/vendor/htm.module.js"
}
}
</script>
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg" />
<link rel="alternate icon" href="/static/img/favicon.ico" />
<link rel="stylesheet" href="/static/css/app.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/static/js/app.js"></script>
</body>
</html>
@@ -0,0 +1,40 @@
const JSON_HEADERS = { 'Content-Type': 'application/json' };
async function req(url, opts = {}) {
const r = await fetch(url, opts);
return r.json();
}
export const api = {
devices: () => req('/api/devices'),
device: (id) => req(`/api/device/${id}`),
discover: () => req('/api/discover', { method: 'POST' }),
key: (id, key) => req(`/api/device-key/${id}/${key}`, { method: 'POST' }),
volume: (id, level) => req(`/api/device-volume/${id}/${level}`, { method: 'POST' }),
bass: (id, level) => req(`/api/control/${id}/bass`, {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify({ level }),
}),
power: (id) => req(`/api/device-power/${id}`, { method: 'POST' }),
recents: (id) => req(`/api/device-recents/${id}`),
zone: (id) => req(`/api/zone/${id}`),
zoneAdd: (masterId, slaveId) => req(`/api/zone/${masterId}/add/${slaveId}`, { method: 'POST' }),
zoneRemove: (masterId, slaveId) => req(`/api/zone/${masterId}/remove/${slaveId}`, { method: 'POST' }),
zoneDissolve: (id) => req(`/api/zone/${id}/dissolve`, { method: 'POST' }),
zoneLeave: (id) => req(`/api/zone/${id}/leave`, { method: 'POST' }),
play: (id, item) => req(`/api/device-play/${id}`, {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify(item),
}),
tuneInBrowse: (path) => req(path ? `/api/tunein/navigate/${path}` : '/api/tunein/navigate'),
tuneInSearch: (q) => req(`/api/tunein/search?q=${encodeURIComponent(q)}`),
control: (id, action, presetId) => req(`/api/control/${id}/${action}?id=${presetId}`),
selectSource: (id, source, account) => req(`/api/control/${id}/source?name=${encodeURIComponent(source)}&account=${encodeURIComponent(account || '')}`),
tuneInPlay: (deviceId, item) => req(`/api/tunein/play/${deviceId}`, {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify(item),
}),
};
+141
View File
@@ -0,0 +1,141 @@
import { h, render } from 'preact';
import { useState, useEffect, useCallback } from 'preact/hooks';
import htm from 'htm';
import { DeviceList } from './components/DeviceList.js';
import { NowPlaying } from './components/NowPlaying.js';
import { Controls } from './components/Controls.js';
import { Presets } from './components/Presets.js';
import { Sources } from './components/Sources.js';
import { Zone } from './components/Zone.js';
import { Recents } from './components/Recents.js';
import { TuneInBrowser } from './components/TuneInBrowser.js';
import { api } from './api.js';
const html = htm.bind(h);
function DeviceDetail({ deviceId, devices, onBack }) {
const device = devices[deviceId];
if (!device) {
return html`
<div class="page-header">
<button class="back-btn" onClick=${onBack}> Back</button>
</div>
<p>Device not found.</p>
`;
}
return html`
<div class="device-detail">
<div class="page-header">
<button class="back-btn" onClick=${onBack}> Back</button>
<h2>${device.info?.Name || deviceId}</h2>
<button class="btn-icon" onClick=${() => api.power(deviceId)} title="Power"></button>
</div>
<${NowPlaying} nowPlaying=${device.status?.nowPlaying} />
<${Controls} deviceId=${deviceId} status=${device.status} />
<${Presets} deviceId=${deviceId} status=${device.status} />
<${Sources} deviceId=${deviceId} status=${device.status} />
<${Zone} deviceId=${deviceId} devices=${devices} />
<${Recents} deviceId=${deviceId} />
</div>
`;
}
function App() {
const [devices, setDevices] = useState({});
const [page, setPage] = useState('devices');
const [selectedId, setSelectedId] = useState(null);
const [toast, setToast] = useState(null);
useEffect(() => {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${location.host}/ws`);
let reconnectTimer;
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'devices') {
setDevices(msg.data || {});
} else if (msg.type === 'discovery_status') {
if (msg.data?.status === 'completed') {
showToast(`Found ${msg.data.deviceCount} device(s)`);
}
} else if (msg.type === 'status_update' && msg.deviceId) {
setDevices(prev => ({
...prev,
[msg.deviceId]: { ...prev[msg.deviceId], status: msg.data },
}));
}
};
ws.onclose = () => {
reconnectTimer = setTimeout(() => location.reload(), 5000);
};
return () => {
clearTimeout(reconnectTimer);
ws.close();
};
}, []);
function showToast(msg) {
setToast(msg);
setTimeout(() => setToast(null), 3000);
}
const navigate = useCallback((p, id = null) => {
setPage(p);
setSelectedId(id);
}, []);
async function discover() {
showToast('Discovering devices…');
await api.discover();
}
return html`
<div class="app">
<nav class="navbar">
<a class="brand" href="#" onClick=${(e) => { e.preventDefault(); navigate('devices'); }}>
SoundTouch
</a>
<div class="nav-links">
<a href="#" class="${page === 'devices' || page === 'device' ? 'active' : ''}"
onClick=${(e) => { e.preventDefault(); navigate('devices'); }}>
Devices
</a>
<a href="#" class="${page === 'tunein' ? 'active' : ''}"
onClick=${(e) => { e.preventDefault(); navigate('tunein'); }}>
<img src="/static/img/tunein-mono.svg" alt="TuneIn" class="nav-tunein-icon" />
</a>
<button class="btn-icon" onClick=${discover} title="Discover"></button>
</div>
</nav>
<main class="main-content">
${page === 'devices' && html`
<${DeviceList}
devices=${devices}
onSelect=${(id) => navigate('device', id)}
onDiscover=${discover}
/>
`}
${page === 'device' && html`
<${DeviceDetail}
deviceId=${selectedId}
devices=${devices}
onBack=${() => navigate('devices')}
/>
`}
${page === 'tunein' && html`
<${TuneInBrowser} devices=${devices} />
`}
</main>
${toast && html`<div class="toast">${toast}</div>`}
</div>
`;
}
render(html`<${App} />`, document.getElementById('app'));
@@ -0,0 +1,80 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import htm from 'htm';
import { api } from '../api.js';
const html = htm.bind(h);
export function Controls({ deviceId, status }) {
const np = status?.nowPlaying;
const isPlaying = np?.PlayStatus === 'PLAY_STATE';
const actualVolume = status?.volume?.ActualVolume ?? 0;
const isMuted = status?.volume?.MuteEnabled ?? false;
const shuffle = np?.ShuffleSetting ?? 'SHUFFLE_OFF';
const repeat = np?.RepeatSetting ?? 'REPEAT_OFF';
const actualBass = status?.bass?.TargetBass ?? 0;
const hasBass = status?.bass != null;
const [localVolume, setLocalVolume] = useState(actualVolume);
const [localBass, setLocalBass] = useState(actualBass);
useEffect(() => { setLocalVolume(actualVolume); }, [actualVolume]);
useEffect(() => { setLocalBass(actualBass); }, [actualBass]);
const send = (key) => api.key(deviceId, key);
function onVolumeChange(e) {
const val = parseInt(e.target.value, 10);
setLocalVolume(val);
api.volume(deviceId, val);
}
function onBassChange(e) {
const val = parseInt(e.target.value, 10);
setLocalBass(val);
api.bass(deviceId, val);
}
function toggleShuffle() {
send(shuffle === 'SHUFFLE_ON' ? 'SHUFFLE_OFF' : 'SHUFFLE_ON');
}
function cycleRepeat() {
if (repeat === 'REPEAT_OFF') send('REPEAT_ALL');
else if (repeat === 'REPEAT_ALL') send('REPEAT_ONE');
else send('REPEAT_OFF');
}
const repeatIcon = repeat === 'REPEAT_ONE' ? '🔂' : '🔁';
return html`
<div class="controls">
<div class="transport">
<button class="ctrl-btn" onClick=${() => send('PREV_TRACK')} title="Previous"></button>
<button class="ctrl-btn play-btn" onClick=${() => send(isPlaying ? 'PAUSE' : 'PLAY')}>
${isPlaying ? '⏸' : '▶'}
</button>
<button class="ctrl-btn" onClick=${() => send('NEXT_TRACK')} title="Next"></button>
<button class="ctrl-btn ${isMuted ? 'active' : ''}" onClick=${() => send('MUTE')} title="Mute">
${isMuted ? '🔇' : '🔊'}
</button>
<button class="ctrl-btn ${shuffle === 'SHUFFLE_ON' ? 'active' : ''}" onClick=${toggleShuffle} title="Shuffle">🔀</button>
<button class="ctrl-btn ${repeat !== 'REPEAT_OFF' ? 'active' : ''}" onClick=${cycleRepeat} title="Repeat">${repeatIcon}</button>
</div>
<div class="volume-row">
<span class="volume-icon">🔈</span>
<input type="range" class="volume-slider" min="0" max="100"
value=${localVolume} onInput=${onVolumeChange} />
<span class="volume-value">${localVolume}</span>
</div>
${hasBass && html`
<div class="bass-row">
<span class="bass-label">Bass</span>
<input type="range" class="volume-slider" min="-9" max="9"
value=${localBass} onInput=${onBassChange} />
<span class="volume-value">${localBass > 0 ? '+' : ''}${localBass}</span>
</div>
`}
</div>
`;
}
@@ -0,0 +1,54 @@
import { h } from 'preact';
import htm from 'htm';
const html = htm.bind(h);
function DeviceCard({ id, device, onSelect }) {
const { info, status } = device;
const np = status?.nowPlaying;
const isPlaying = np?.PlayStatus === 'PLAY_STATE';
const isStandby = !np || np.Source === 'STANDBY';
return html`
<div class="device-card" onClick=${() => onSelect(id)}>
<div class="device-header">
<span class="device-name">${info?.Name || id}</span>
<span class="device-indicator ${status?.isConnected ? 'online' : 'offline'}"></span>
</div>
<div class="device-type">${info?.Type || ''}</div>
${!isStandby && html`
<div class="now-playing-mini">
<span class="play-status">${isPlaying ? '▶' : '⏸'}</span>
<span class="track-mini">${np.Track || np.StationName || np.Source}</span>
${np.Artist && html`<span class="artist-mini"> — ${np.Artist}</span>`}
</div>
`}
${isStandby && html`<div class="standby-label">Standby</div>`}
</div>
`;
}
export function DeviceList({ devices, onSelect, onDiscover }) {
const entries = Object.entries(devices);
return html`
<div class="page-header">
<h2>Devices</h2>
<button class="btn-secondary" onClick=${onDiscover}>Discover</button>
</div>
${entries.length === 0
? html`
<div class="empty-state">
<div class="empty-icon"></div>
<p>No devices found on your network.</p>
<button class="btn-primary" onClick=${onDiscover}>Start Discovery</button>
</div>`
: html`
<div class="device-grid">
${entries.map(([id, device]) => html`
<${DeviceCard} key=${id} id=${id} device=${device} onSelect=${onSelect} />
`)}
</div>`
}
`;
}
@@ -0,0 +1,57 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import htm from 'htm';
const html = htm.bind(h);
function fmt(secs) {
if (!secs || secs <= 0) return '0:00';
const m = Math.floor(secs / 60);
const s = secs % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
export function NowPlaying({ nowPlaying }) {
const [position, setPosition] = useState(0);
useEffect(() => {
const pos = nowPlaying?.Time?.Position ?? 0;
setPosition(pos);
if (nowPlaying?.PlayStatus !== 'PLAY_STATE') return;
const id = setInterval(() => setPosition(p => p + 1), 1000);
return () => clearInterval(id);
}, [nowPlaying?.Time?.Position, nowPlaying?.PlayStatus]);
if (!nowPlaying || nowPlaying.Source === 'STANDBY') {
return html`<div class="now-playing standby">Standby</div>`;
}
const title = nowPlaying.Track || nowPlaying.StationName || nowPlaying.Source;
const artURL = nowPlaying.Art?.URL;
const isBuffering = nowPlaying.PlayStatus === 'BUFFERING_STATE';
const total = nowPlaying.Time?.Total ?? 0;
const pct = total > 0 ? Math.min(100, (position / total) * 100) : 0;
return html`
<div class="now-playing">
${artURL && html`<img class="album-art" src=${artURL} alt="" />`}
<div class="track-info">
<div class="track-title">${title}</div>
${nowPlaying.Artist && html`<div class="track-artist">${nowPlaying.Artist}</div>`}
${nowPlaying.Album && html`<div class="track-album">${nowPlaying.Album}</div>`}
<div class="track-meta">
<span class="track-source">${nowPlaying.Source}</span>
${isBuffering && html`<span class="buffering-badge">Buffering…</span>`}
</div>
${total > 0 && html`
<div class="progress-row">
<div class="progress-bar">
<div class="progress-fill" style="width:${pct}%"></div>
</div>
<span class="progress-time">${fmt(position)} / ${fmt(total)}</span>
</div>
`}
</div>
</div>
`;
}
@@ -0,0 +1,73 @@
import { h } from 'preact';
import htm from 'htm';
import { api } from '../api.js';
const html = htm.bind(h);
const SOURCE_LABELS = {
TUNEIN: 'TuneIn', SPOTIFY: 'Spotify', AMAZON: 'Amazon',
PANDORA: 'Pandora', IHEARTRADIO: 'iHeart', DEEZER: 'Deezer',
LOCAL_INTERNET_RADIO: 'Internet Radio',
};
function sourceLabel(source) {
return SOURCE_LABELS[source] || source;
}
function PresetSlot({ preset, deviceId, active }) {
const item = preset?.ContentItem;
const isEmpty = !item;
const art = item?.ContainerArt;
const name = item?.ItemName || `Preset ${preset?.ID ?? ''}`;
function select() {
if (!isEmpty) api.control(deviceId, 'preset', preset.ID);
}
return html`
<button
class="preset-slot ${isEmpty ? 'empty' : ''} ${active ? 'active' : ''}"
onClick=${select}
disabled=${isEmpty}
title=${isEmpty ? 'Empty' : name}
>
${art
? html`<img class="preset-art" src=${art} alt="" />`
: html`<span class="preset-source-label">${isEmpty ? '—' : sourceLabel(item.Source)}</span>`
}
<span class="preset-name">${isEmpty ? 'Empty' : name}</span>
<span class="preset-num">${preset?.ID ?? ''}</span>
</button>
`;
}
export function Presets({ deviceId, status }) {
const presets = status?.presets?.Preset ?? [];
const currentSource = status?.nowPlaying?.Source;
const currentLocation = status?.nowPlaying?.ContentItem?.Location;
// Build a map for quick lookup, then render slots 1-6
const byId = Object.fromEntries(presets.map(p => [p.ID, p]));
const slots = [1, 2, 3, 4, 5, 6].map(id => byId[id] ?? { ID: id, ContentItem: null });
function isActive(preset) {
const item = preset.ContentItem;
return item && item.Source === currentSource && item.Location === currentLocation;
}
return html`
<div class="presets-section">
<h3 class="section-title">Presets</h3>
<div class="preset-grid">
${slots.map(preset => html`
<${PresetSlot}
key=${preset.ID}
preset=${preset}
deviceId=${deviceId}
active=${isActive(preset)}
/>
`)}
</div>
</div>
`;
}
@@ -0,0 +1,75 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import htm from 'htm';
import { api } from '../api.js';
const html = htm.bind(h);
const SOURCE_ICONS = {
TUNEIN: '📻', SPOTIFY: '🎵', AMAZON: '🎶', PANDORA: '🎸',
DEEZER: '🎵', IHEART: '📻', BLUETOOTH: '📶', AUX: '🔌',
LOCAL_MUSIC: '💽', STORED_MUSIC: '💽',
};
export function Recents({ deviceId }) {
const [items, setItems] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!deviceId) return;
api.recents(deviceId).then(resp => {
setItems(resp.data?.Items ?? []);
}).catch(() => {
setItems([]);
}).finally(() => setLoading(false));
}, [deviceId]);
if (loading) return html`
<div class="recents-section">
<div class="section-title">Recents</div>
<div class="loading-bar"></div>
</div>
`;
if (!items || items.length === 0) return null;
function play(item) {
const ci = item.ContentItem;
if (!ci?.Location) return;
api.play(deviceId, {
source: ci.Source,
type: ci.Type,
location: ci.Location,
sourceAccount: ci.SourceAccount,
itemName: ci.ItemName,
containerArt: ci.ContainerArt,
isPresetable: ci.IsPresetable,
});
}
return html`
<div class="recents-section">
<div class="section-title">Recents</div>
<div class="recents-list">
${items.map(item => {
const ci = item.ContentItem;
if (!ci) return null;
const icon = SOURCE_ICONS[ci.Source] ?? '♪';
return html`
<button class="recent-item" key=${item.ID || item.UTCTime} onClick=${() => play(item)}>
${ci.ContainerArt
? html`<img class="recent-art" src=${ci.ContainerArt} alt="" />`
: html`<div class="recent-art recent-art-empty">${icon}</div>`
}
<div class="recent-info">
<span class="recent-name">${ci.ItemName || ci.Source}</span>
<span class="recent-source">${ci.Source}</span>
</div>
<span class="recent-play"></span>
</button>
`;
})}
</div>
</div>
`;
}
@@ -0,0 +1,48 @@
import { h } from 'preact';
import htm from 'htm';
import { api } from '../api.js';
const html = htm.bind(h);
const SOURCE_ICONS = {
TUNEIN: '📻', SPOTIFY: '🎵', AMAZON: '🛒', PANDORA: '🎶',
BLUETOOTH: '📶', AUX: '🔌', OPTICAL: '💡', HDMI: '📺',
IHEARTRADIO: '❤️', DEEZER: '🎼', LOCAL_INTERNET_RADIO: '📡',
AIRPLAY: '📡', PRODUCT: '🔊',
};
export function Sources({ deviceId, status }) {
const items = status?.sources?.SourceItem ?? [];
const currentSource = status?.nowPlaying?.Source;
const currentAccount = status?.nowPlaying?.SourceAccount;
const ready = items.filter(s => s.Status === 'READY');
if (ready.length === 0) return null;
function select(src) {
api.selectSource(deviceId, src.Source, src.SourceAccount ?? '');
}
return html`
<div class="sources-section">
<h3 class="section-title">Sources</h3>
<div class="source-list">
${ready.map(src => {
const isActive = src.Source === currentSource &&
(!src.SourceAccount || src.SourceAccount === currentAccount);
return html`
<button
key=${src.Source + (src.SourceAccount || '')}
class="source-btn ${isActive ? 'active' : ''} ${src.IsLocal ? 'local' : ''}"
onClick=${() => select(src)}
title=${src.Source}
>
<span class="source-icon">${SOURCE_ICONS[src.Source] || '🔊'}</span>
<span class="source-name">${src.DisplayName || src.Source}</span>
</button>
`;
})}
</div>
</div>
`;
}
@@ -0,0 +1,149 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import htm from 'htm';
import { api } from '../api.js';
const html = htm.bind(h);
// BmxNavResponse has shape { bmx_sections: [{ name, items: [{ name, imageUrl, subtitle, _links }] }] }
// _links.bmx_navigate.href = "/v1/navigate/{encodedPath}" — strip prefix for API call
// _links.bmx_playback.href = station/track URL, type = "stationurl"|"tracklisturl"
function navPath(item) {
const href = item._links?.bmx_navigate?.href;
return href ? href.replace(/^\/v1\/navigate\//, '') : null;
}
function playbackInfo(item) {
const link = item._links?.bmx_playback;
return link ? { location: link.href, type: link.type || 'stationurl' } : null;
}
function flattenSections(data) {
if (!data?.bmx_sections) return [];
return data.bmx_sections.flatMap(section =>
(section.items || []).map(item => ({ ...item, _sectionName: section.name }))
);
}
export function TuneInBrowser({ devices }) {
const [items, setItems] = useState([]);
const [navStack, setNavStack] = useState([{ label: 'TuneIn', path: null }]);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(false);
const [pendingPlay, setPendingPlay] = useState(null);
useEffect(() => { browse(null); }, []);
async function browse(path) {
setLoading(true);
const resp = await api.tuneInBrowse(path);
setLoading(false);
if (resp.success) setItems(flattenSections(resp.data));
}
async function search(q) {
if (!q.trim()) return;
setLoading(true);
const resp = await api.tuneInSearch(q);
setLoading(false);
if (resp.success) {
setNavStack([{ label: 'TuneIn', path: null }, { label: `"${q}"`, path: null }]);
setItems(flattenSections(resp.data));
}
}
function navigate(item) {
const path = navPath(item);
const play = playbackInfo(item);
if (path) {
setNavStack(s => [...s, { label: item.name, path }]);
browse(path);
} else if (play) {
setPendingPlay({ ...play, name: item.name, image: item.imageUrl });
}
}
function navTo(index) {
const stack = navStack.slice(0, index + 1);
setNavStack(stack);
browse(stack[stack.length - 1].path);
}
async function playOn(deviceId) {
await api.tuneInPlay(deviceId, { location: pendingPlay.location, type: pendingPlay.type, name: pendingPlay.name });
setPendingPlay(null);
}
const deviceEntries = Object.entries(devices);
return html`
<div class="tunein-browser">
<div class="tunein-toolbar">
<input
type="text"
class="tunein-search-input"
placeholder="Search stations, podcasts…"
value=${searchQuery}
onInput=${(e) => setSearchQuery(e.target.value)}
onKeyDown=${(e) => e.key === 'Enter' && search(searchQuery)}
/>
<button class="btn-primary" onClick=${() => search(searchQuery)}>Search</button>
<button class="btn-secondary" onClick=${() => {
setNavStack([{ label: 'TuneIn', path: null }]);
setSearchQuery('');
browse(null);
}}>Browse</button>
</div>
${navStack.length > 1 && html`
<nav class="breadcrumb">
${navStack.map((entry, i) => html`
${i > 0 && html`<span class="breadcrumb-sep"></span>`}
${i < navStack.length - 1
? html`<a class="breadcrumb-link" onClick=${() => navTo(i)}>${entry.label}</a>`
: html`<span class="breadcrumb-current">${entry.label}</span>`
}
`)}
</nav>
`}
${loading && html`<div class="loading-bar"></div>`}
<ul class="tunein-list">
${items.map((item, i) => {
const isNav = !!navPath(item);
return html`
<li key=${item._links?.self?.href || i} class="tunein-item" onClick=${() => navigate(item)}>
${item.imageUrl && html`<img class="tunein-thumb" src=${item.imageUrl} alt="" />`}
<div class="tunein-item-info">
<span class="tunein-item-name">${item.name}</span>
${item.subtitle && html`<span class="tunein-item-desc">${item.subtitle}</span>`}
</div>
<span class="tunein-item-arrow">${isNav ? '' : '▶'}</span>
</li>
`;
})}
</ul>
${pendingPlay && html`
<div class="overlay" onClick=${() => setPendingPlay(null)}>
<div class="device-picker" onClick=${(e) => e.stopPropagation()}>
<h3 class="picker-title">Play on device</h3>
<p class="picker-item-name">${pendingPlay.name}</p>
<div class="picker-devices">
${deviceEntries.length === 0 && html`<p class="picker-no-devices">No devices found. Try discovering first.</p>`}
${deviceEntries.map(([id, d]) => html`
<button class="picker-device-btn" onClick=${() => playOn(id)}>
${d.info?.name || id}
</button>
`)}
</div>
<button class="btn-secondary picker-cancel" onClick=${() => setPendingPlay(null)}>Cancel</button>
</div>
</div>
`}
</div>
`;
}
@@ -0,0 +1,118 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import htm from 'htm';
import { api } from '../api.js';
const html = htm.bind(h);
export function Zone({ deviceId, devices }) {
const [zone, setZone] = useState(null);
const [loading, setLoading] = useState(true);
const [showPicker, setShowPicker] = useState(false);
function refresh() {
api.zone(deviceId).then(resp => {
if (resp.success) setZone(resp.data);
}).finally(() => setLoading(false));
}
useEffect(() => { refresh(); }, [deviceId]);
async function addDevice(slaveId) {
setShowPicker(false);
await api.zoneAdd(deviceId, slaveId);
refresh();
}
async function removeDevice(slaveId) {
await api.zoneRemove(deviceId, slaveId);
refresh();
}
async function dissolve() {
await api.zoneDissolve(deviceId);
refresh();
}
async function leave() {
await api.zoneLeave(deviceId);
refresh();
}
if (loading) return html`
<div class="zone-section">
<div class="section-title">Zone</div>
<div class="loading-bar"></div>
</div>
`;
if (!zone) return null;
// Devices not already in the zone are available to add
const zoneIps = new Set([zone.masterIp, ...(zone.members || []).map(m => m.ip)].filter(Boolean));
const available = Object.entries(devices || {}).filter(([ip]) => !zoneIps.has(ip));
const deviceName = (ip) => devices[ip]?.info?.Name ?? ip;
return html`
<div class="zone-section">
<div class="section-title">Zone</div>
${zone.isStandalone && html`
<div class="zone-row">
<span class="zone-status-label">Standalone</span>
${available.length > 0 && html`
<button class="btn-secondary zone-btn" onClick=${() => setShowPicker(true)}>+ Group with</button>
`}
</div>
`}
${zone.isMaster && html`
<div class="zone-members">
<div class="zone-member zone-master-row">
<span class="zone-badge master">Master</span>
<span class="zone-member-name">${deviceName(deviceId)}</span>
</div>
${(zone.members || []).map(m => html`
<div class="zone-member" key=${m.ip}>
<span class="zone-badge slave">Member</span>
<span class="zone-member-name">${m.name || m.ip}</span>
<button class="btn-icon zone-remove" title="Remove from zone"
onClick=${() => removeDevice(m.ip)}></button>
</div>
`)}
<div class="zone-actions">
${available.length > 0 && html`
<button class="btn-secondary zone-btn" onClick=${() => setShowPicker(true)}>+ Add speaker</button>
`}
<button class="btn-secondary zone-btn" onClick=${dissolve}>Dissolve zone</button>
</div>
</div>
`}
${zone.isSlave && html`
<div class="zone-row">
<span class="zone-badge slave">Member</span>
<span class="zone-member-name">Zone: ${zone.masterName || zone.masterIp}</span>
<button class="btn-secondary zone-btn" onClick=${leave}>Leave zone</button>
</div>
`}
${showPicker && html`
<div class="overlay" onClick=${() => setShowPicker(false)}>
<div class="device-picker" onClick=${e => e.stopPropagation()}>
<div class="picker-title">Add to zone</div>
<div class="picker-devices">
${available.map(([ip, d]) => html`
<button class="picker-device-btn" key=${ip} onClick=${() => addDevice(ip)}>
${d.info?.Name ?? ip}
</button>
`)}
</div>
<button class="btn-secondary picker-cancel" onClick=${() => setShowPicker(false)}>Cancel</button>
</div>
</div>
`}
</div>
`;
}
@@ -1,5 +1,4 @@
// Package handlers contains WebSocket handlers for real-time communication.
package handlers
package soundtouchweb
import (
"encoding/json"
@@ -7,13 +6,13 @@ import (
"net/http"
"time"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
)
// HandleWebSocket handles WebSocket connections for real-time updates
// HandleWebSocket handles browser WebSocket connections for real-time updates.
func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := app.Upgrader.Upgrade(w, r, nil)
if err != nil {
@@ -22,19 +21,16 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
}
defer func() {
// Unregister client
app.WSMutex.Lock()
delete(app.WSClients, conn)
app.WSMutex.Unlock()
conn.Close()
}()
// Register client
app.WSMutex.Lock()
app.WSClients[conn] = true
app.WSMutex.Unlock()
// Send initial device list
devices := make(map[string]interface{})
for id, device := range app.Devices {
devices[id] = map[string]interface{}{
@@ -44,30 +40,20 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
}
}
initialMessage := webtypes.WebSocketMessage{
Type: "devices",
Data: devices,
}
if err := conn.WriteJSON(initialMessage); err != nil {
if err := conn.WriteJSON(webtypes.WebSocketMessage{Type: "devices", Data: devices}); err != nil {
log.Printf("Failed to send initial data: %v", err)
return
}
// Keep connection alive and send updates
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
// Set up ping handler to detect client disconnects
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
// Set initial read deadline
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
// Handle incoming messages in a separate goroutine
go func() {
defer conn.Close()
@@ -79,24 +65,19 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
}
}()
// Main loop for sending periodic updates
for range ticker.C {
// Send ping to check if client is still connected
if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
log.Printf("Failed to send ping: %v", err)
return
}
// Send periodic status updates
for id, device := range app.Devices {
if device.Status.IsConnected {
statusMessage := webtypes.WebSocketMessage{
if err := conn.WriteJSON(webtypes.WebSocketMessage{
Type: "status_update",
DeviceID: id,
Data: device.Status,
}
if err := conn.WriteJSON(statusMessage); err != nil {
}); err != nil {
log.Printf("Failed to send status update: %v", err)
return
}
@@ -105,36 +86,31 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
}
}
// HandleAPIDiscover triggers device discovery
// HandleAPIDiscover acknowledges a discovery request (actual discovery is triggered by Mount).
func (app *WebApp) HandleAPIDiscover(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
app.sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Discovery will be triggered by the main app
w.Header().Set("Content-Type", "application/json")
response := webtypes.APIResponse{
if err := json.NewEncoder(w).Encode(webtypes.APIResponse{
Success: true,
Data: map[string]string{"message": "Discovery started"},
}
if err := json.NewEncoder(w).Encode(response); err != nil {
}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// ConnectDeviceWebSocket establishes a WebSocket connection to a device
// ConnectDeviceWebSocket establishes a WebSocket connection to a SoundTouch device.
func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.DeviceConnection) {
// Skip WebSocket connection if client is not available (e.g., in tests)
if conn.Client == nil {
return
}
wsClient := conn.Client.NewWebSocketClient(nil)
// Setup event handlers
wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) {
conn.Status.NowPlaying = &event.NowPlaying
conn.Status.LastActivity = time.Now()
@@ -155,7 +131,6 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
conn.Status.LastActivity = time.Now()
})
// Connect WebSocket
if err := wsClient.Connect(); err != nil {
log.Printf("Failed to connect WebSocket for device %s: %v", deviceID, err)
return
@@ -166,7 +141,6 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
log.Printf("WebSocket connected for device %s", deviceID)
// Wait for disconnection
wsClient.Wait()
conn.Status.IsConnected = false
@@ -174,55 +148,46 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
log.Printf("WebSocket disconnected for device %s", deviceID)
}
// UpdateDeviceStatus fetches current status from device
// UpdateDeviceStatus fetches current status from a device.
func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection) {
// Skip status update if client is not available (e.g., in tests)
if conn.Client == nil {
return
}
statusUpdated := false
// Get current now playing
if nowPlaying, err := conn.Client.GetNowPlaying(); err == nil {
conn.Status.NowPlaying = nowPlaying
statusUpdated = true
}
// Get current volume
if volume, err := conn.Client.GetVolume(); err == nil {
conn.Status.Volume = volume
statusUpdated = true
}
// Get presets
if presets, err := conn.Client.GetPresets(); err == nil {
conn.Status.Presets = presets
statusUpdated = true
}
// Update last activity if any status was updated
if statusUpdated {
conn.Status.LastActivity = time.Now()
}
// Get sources
if sources, err := conn.Client.GetSources(); err == nil {
conn.Status.Sources = sources
statusUpdated = true
}
// Get bass (if available)
if bass, err := conn.Client.GetBass(); err == nil {
conn.Status.Bass = bass
statusUpdated = true
}
// Mark as connected if we successfully got at least one status
conn.Status.IsConnected = statusUpdated
conn.Status.LastActivity = time.Now()
}
// HandleDeviceWebSocket handles individual device WebSocket connections for real-time device-specific updates
// HandleDeviceWebSocket handles per-device WebSocket connections for real-time device-specific updates.
func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
if deviceID == "" {
@@ -245,31 +210,21 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
log.Printf("Device WebSocket connected for %s", deviceID)
// Send initial device status
initialMessage := webtypes.WebSocketMessage{
if err := conn.WriteJSON(webtypes.WebSocketMessage{
Type: "device_status",
DeviceID: deviceID,
Data: map[string]interface{}{
"info": device.DeviceInfo,
"status": device.Status,
},
}
if err := conn.WriteJSON(initialMessage); err != nil {
Data: map[string]interface{}{"info": device.DeviceInfo, "status": device.Status},
}); err != nil {
log.Printf("Failed to send initial device status: %v", err)
return
}
// Set up ping handler to detect client disconnects
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
// Set initial read deadline
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
// Handle incoming messages in a separate goroutine
go func() {
defer conn.Close()
@@ -281,36 +236,26 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
}
}()
// Send periodic device status updates
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
// Send ping to check if client is still connected
if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
log.Printf("Failed to send ping to device WebSocket %s: %v", deviceID, err)
return
}
// Send device status update
statusMessage := webtypes.WebSocketMessage{
if err := conn.WriteJSON(webtypes.WebSocketMessage{
Type: "device_status",
DeviceID: deviceID,
Data: map[string]interface{}{
"info": device.DeviceInfo,
"status": device.Status,
},
}
if err := conn.WriteJSON(statusMessage); err != nil {
Data: map[string]interface{}{"info": device.DeviceInfo, "status": device.Status},
}); err != nil {
log.Printf("Failed to send device status update for %s: %v", deviceID, err)
return
}
// If device has active WebSocket connection to SoundTouch device,
// also send any real-time updates from that connection
if device.WebSocket != nil && device.Status.IsConnected {
realtimeMessage := webtypes.WebSocketMessage{
if err := conn.WriteJSON(webtypes.WebSocketMessage{
Type: "device_realtime",
DeviceID: deviceID,
Data: map[string]interface{}{
@@ -318,12 +263,57 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
"volume": device.Status.Volume,
"timestamp": time.Now(),
},
}
if err := conn.WriteJSON(realtimeMessage); err != nil {
}); err != nil {
log.Printf("Failed to send realtime update for %s: %v", deviceID, err)
return
}
}
}
}
// BroadcastDeviceList sends the updated device list to all connected browser WebSocket clients.
func (app *WebApp) BroadcastDeviceList() {
app.WSMutex.RLock()
defer app.WSMutex.RUnlock()
devices := make(map[string]interface{})
for id, device := range app.Devices {
devices[id] = map[string]interface{}{
"info": device.DeviceInfo,
"status": device.Status,
"lastSeen": device.LastSeen,
}
}
app.broadcast(webtypes.WebSocketMessage{Type: "devices", Data: devices})
}
// BroadcastDiscoveryStatus sends discovery progress to all connected browser WebSocket clients.
func (app *WebApp) BroadcastDiscoveryStatus(status string, deviceCount int) {
app.WSMutex.RLock()
defer app.WSMutex.RUnlock()
app.broadcast(webtypes.WebSocketMessage{
Type: "discovery_status",
Data: map[string]interface{}{"status": status, "deviceCount": deviceCount},
})
}
// broadcast sends a message to all registered WS clients, removing failed ones.
// Caller must hold at least a read lock on WSMutex.
func (app *WebApp) broadcast(msg webtypes.WebSocketMessage) {
var failed []*websocket.Conn
for client := range app.WSClients {
if err := client.WriteJSON(msg); err != nil {
log.Printf("Failed to broadcast to WebSocket client: %v", err)
failed = append(failed, client)
}
}
for _, client := range failed {
delete(app.WSClients, client)
client.Close()
}
}