mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ce42f3965 | ||
|
|
bc8213f0a1 | ||
|
|
9ca5b88025 | ||
|
|
8c01edaae4 | ||
|
|
759c6da52a | ||
|
|
3da023aa78 | ||
|
|
7d410eef24 | ||
|
|
b2dc2cb802 | ||
|
|
bd2e594ba8 | ||
|
|
4257c100ac | ||
|
|
e008bb6a2b | ||
|
|
1d9264437d | ||
|
|
ff8bf75982 | ||
|
|
dbe5b90d8d | ||
|
|
981ecf6d89 | ||
|
|
37d758f7f3 | ||
|
|
370b587fcf | ||
|
|
e3dac8b5a6 | ||
|
|
0b579a7e59 | ||
|
|
d3b1593953 | ||
|
|
889470716b | ||
|
|
12ca412ed2 | ||
|
|
fb47807f70 | ||
|
|
d9894be7db | ||
|
|
255dd9612a | ||
|
|
cf81fc033f | ||
|
|
653652b57d |
+109
-17
@@ -86,13 +86,24 @@ jobs:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
goos: [linux, darwin, windows]
|
||||
goarch: [amd64, arm64]
|
||||
exclude:
|
||||
# Windows ARM64 builds are experimental
|
||||
- goos: windows
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
- goos: linux
|
||||
goarch: arm
|
||||
goarm: 7
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
- goos: freebsd
|
||||
goarch: amd64
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -103,22 +114,48 @@ jobs:
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Build CLI
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.mod') }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Build binaries
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOARM: ${{ matrix.goarm }}
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
output_name="soundtouch-cli-${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
output_name="${output_name}.exe"
|
||||
ARCH_SUFFIX="${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||
if [[ -n "${{ matrix.goarm }}" ]]; then
|
||||
ARCH_SUFFIX="${ARCH_SUFFIX}v${{ matrix.goarm }}"
|
||||
fi
|
||||
go build -trimpath -ldflags="-s -w" -o "$output_name" ./cmd/soundtouch-cli
|
||||
|
||||
EXT=""
|
||||
if [[ "${{ matrix.goos }}" == "windows" ]]; then
|
||||
EXT=".exe"
|
||||
fi
|
||||
|
||||
mkdir -p build
|
||||
|
||||
for binary in soundtouch-cli soundtouch-service soundtouch-web soundtouch-backup; do
|
||||
OUTPUT="build/${binary}-${ARCH_SUFFIX}${EXT}"
|
||||
echo "Building $OUTPUT"
|
||||
go build -trimpath -ldflags="-s -w" -o "$OUTPUT" "./cmd/$binary"
|
||||
done
|
||||
|
||||
ls -la build/
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: soundtouch-cli-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: soundtouch-cli-*
|
||||
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
|
||||
path: build/
|
||||
|
||||
security:
|
||||
name: Basic Security Check
|
||||
@@ -271,8 +308,22 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Determine push eligibility
|
||||
id: push-check
|
||||
run: |
|
||||
# Push on main, and on same-repo PRs (forks can't push to GHCR via GITHUB_TOKEN).
|
||||
SHOULD_PUSH="false"
|
||||
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
|
||||
SHOULD_PUSH="true"
|
||||
elif [[ "${{ github.event_name }}" == "pull_request" && \
|
||||
"${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then
|
||||
SHOULD_PUSH="true"
|
||||
fi
|
||||
echo "should-push=$SHOULD_PUSH" >> "$GITHUB_OUTPUT"
|
||||
echo "Will push: $SHOULD_PUSH"
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
if: steps.push-check.outputs.should-push == 'true'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -286,7 +337,9 @@ jobs:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=ref,event=pr
|
||||
type=ref,event=pr,prefix=preview-pr-
|
||||
type=sha,prefix=preview-sha-,format=short,enable=${{ github.event_name == 'pull_request' }}
|
||||
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push soundtouch-service Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
@@ -294,7 +347,7 @@ jobs:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
push: ${{ steps.push-check.outputs.should-push == 'true' }}
|
||||
tags: ${{ steps.meta-service.outputs.tags }}
|
||||
labels: ${{ steps.meta-service.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
@@ -307,7 +360,9 @@ jobs:
|
||||
images: ghcr.io/${{ github.repository }}-web
|
||||
tags: |
|
||||
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=ref,event=pr
|
||||
type=ref,event=pr,prefix=preview-pr-
|
||||
type=sha,prefix=preview-sha-,format=short,enable=${{ github.event_name == 'pull_request' }}
|
||||
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push soundtouch-web Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
@@ -315,12 +370,49 @@ jobs:
|
||||
context: .
|
||||
target: soundtouch-web
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
push: ${{ steps.push-check.outputs.should-push == 'true' }}
|
||||
tags: ${{ steps.meta-web.outputs.tags }}
|
||||
labels: ${{ steps.meta-web.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Summarize published images
|
||||
if: steps.push-check.outputs.should-push == 'true'
|
||||
env:
|
||||
SERVICE_TAGS: ${{ steps.meta-service.outputs.tags }}
|
||||
WEB_TAGS: ${{ steps.meta-web.outputs.tags }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
{
|
||||
echo "## 🐳 Published Docker Images"
|
||||
echo ""
|
||||
if [[ "$EVENT_NAME" == "pull_request" ]]; then
|
||||
echo "**Preview** images for PR #${PR_NUMBER}. These are not release builds."
|
||||
elif [[ "$REF_NAME" == "main" ]]; then
|
||||
echo "**Edge** images from \`main\`."
|
||||
else
|
||||
echo "**Preview** images from branch \`${REF_NAME}\`. These are not release builds."
|
||||
fi
|
||||
echo ""
|
||||
echo "### soundtouch-service"
|
||||
echo ""
|
||||
echo '```bash'
|
||||
while IFS= read -r tag; do
|
||||
[[ -n "$tag" ]] && echo "docker pull $tag"
|
||||
done <<< "$SERVICE_TAGS"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### soundtouch-web"
|
||||
echo ""
|
||||
echo '```bash'
|
||||
while IFS= read -r tag; do
|
||||
[[ -n "$tag" ]] && echo "docker pull $tag"
|
||||
done <<< "$WEB_TAGS"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
notify:
|
||||
name: Notify Status
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -20,6 +20,8 @@ See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVI
|
||||
|
||||
A local server that replaces the Bose cloud ("AfterTouch"). Once your speaker is redirected to it, you have full control without any Bose cloud dependency. The built-in web UI at `http://localhost:8000` handles all setup — no config files needed to get started.
|
||||
|
||||
If you want to run a server for this - no problem. The service is small enough to run on the SoundTouch itself. See the [On-Device Installer](./scripts/on-device-install/README.md) for instructions.
|
||||
|
||||
**Two scenarios:**
|
||||
|
||||
**Before shutdown — migrate your existing setup**
|
||||
|
||||
@@ -1070,6 +1070,8 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
|
||||
r.Post("/revert/{deviceId}", server.HandleRevertMigration)
|
||||
r.Post("/reboot/{deviceId}", server.HandleRebootDevice)
|
||||
r.Get("/account-id-suggestions/{deviceId}", server.HandleAccountIDSuggestions)
|
||||
r.Post("/pair-account/{deviceId}", server.HandlePairAccount)
|
||||
r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services/{deviceId}", server.HandleRemoveRemoteServices)
|
||||
|
||||
@@ -48,6 +48,7 @@ GET /mgmt/spotify/callback handlers.(
|
||||
GET /mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
|
||||
GET /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
GET /proxy/* handlers.(*Server).HandleProxyRequest-fm
|
||||
GET /setup/account-id-suggestions/{deviceId} handlers.(*Server).HandleAccountIDSuggestions-fm
|
||||
GET /setup/ca.crt handlers.(*Server).HandleGetCACert-fm
|
||||
GET /setup/devices handlers.(*Server).HandleListDiscoveredDevices-fm
|
||||
GET /setup/devices/{deviceId}/events handlers.(*Server).HandleGetDeviceEvents-fm
|
||||
@@ -121,6 +122,7 @@ POST /setup/devices handlers.(
|
||||
POST /setup/discover handlers.(*Server).HandleTriggerDiscovery-fm
|
||||
POST /setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
|
||||
POST /setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
|
||||
POST /setup/pair-account/{deviceId} handlers.(*Server).HandlePairAccount-fm
|
||||
POST /setup/proxy-settings handlers.(*Server).HandleUpdateProxySettings-fm
|
||||
POST /setup/reboot/{deviceId} handlers.(*Server).HandleRebootDevice-fm
|
||||
POST /setup/remove-remote-services/{deviceId} handlers.(*Server).HandleRemoveRemoteServices-fm
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -1,4 +1,5 @@
|
||||
package soundtouchweb
|
||||
// Package handlers contains tests for HTTP handlers.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -9,9 +10,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"
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
package soundtouchweb
|
||||
// Package handlers contains WebSocket handlers for real-time communication.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -6,13 +7,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 browser WebSocket connections for real-time updates.
|
||||
// HandleWebSocket handles 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 {
|
||||
@@ -21,16 +22,19 @@ 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{}{
|
||||
@@ -40,20 +44,30 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{Type: "devices", Data: devices}); err != nil {
|
||||
initialMessage := webtypes.WebSocketMessage{
|
||||
Type: "devices",
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(initialMessage); 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()
|
||||
|
||||
@@ -65,19 +79,24 @@ 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 {
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{
|
||||
statusMessage := webtypes.WebSocketMessage{
|
||||
Type: "status_update",
|
||||
DeviceID: id,
|
||||
Data: device.Status,
|
||||
}); err != nil {
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(statusMessage); err != nil {
|
||||
log.Printf("Failed to send status update: %v", err)
|
||||
return
|
||||
}
|
||||
@@ -86,31 +105,36 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDiscover acknowledges a discovery request (actual discovery is triggered by Mount).
|
||||
// HandleAPIDiscover triggers device discovery
|
||||
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")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(webtypes.APIResponse{
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"message": "Discovery started"},
|
||||
}); err != nil {
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// ConnectDeviceWebSocket establishes a WebSocket connection to a SoundTouch device.
|
||||
// ConnectDeviceWebSocket establishes a WebSocket connection to a 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()
|
||||
@@ -131,6 +155,7 @@ 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
|
||||
@@ -141,6 +166,7 @@ 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
|
||||
@@ -148,46 +174,55 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
|
||||
log.Printf("WebSocket disconnected for device %s", deviceID)
|
||||
}
|
||||
|
||||
// UpdateDeviceStatus fetches current status from a device.
|
||||
// UpdateDeviceStatus fetches current status from 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 per-device WebSocket connections for real-time device-specific updates.
|
||||
// HandleDeviceWebSocket handles individual 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 == "" {
|
||||
@@ -210,21 +245,31 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
log.Printf("Device WebSocket connected for %s", deviceID)
|
||||
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{
|
||||
// Send initial device status
|
||||
initialMessage := webtypes.WebSocketMessage{
|
||||
Type: "device_status",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{"info": device.DeviceInfo, "status": device.Status},
|
||||
}); err != nil {
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(initialMessage); 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()
|
||||
|
||||
@@ -236,26 +281,36 @@ 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
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{
|
||||
// Send device status update
|
||||
statusMessage := webtypes.WebSocketMessage{
|
||||
Type: "device_status",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{"info": device.DeviceInfo, "status": device.Status},
|
||||
}); err != nil {
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(statusMessage); 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 {
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{
|
||||
realtimeMessage := webtypes.WebSocketMessage{
|
||||
Type: "device_realtime",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
@@ -263,57 +318,12 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
"volume": device.Status.Volume,
|
||||
"timestamp": time.Now(),
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(realtimeMessage); 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()
|
||||
}
|
||||
}
|
||||
+164
-4
@@ -2,15 +2,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
|
||||
"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/go-chi/chi/v5"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var staticFS embed.FS
|
||||
|
||||
func main() {
|
||||
app := &cli.App{
|
||||
Name: "soundtouch-web",
|
||||
@@ -38,10 +49,36 @@ func main() {
|
||||
addr = bindAddr + ":" + port
|
||||
}
|
||||
|
||||
webApp := soundtouchweb.New()
|
||||
// Create web app without templates (SPA mode)
|
||||
webApp := handlers.NewWebApp()
|
||||
|
||||
r := chi.NewRouter()
|
||||
webApp.Mount(r)
|
||||
// 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)
|
||||
|
||||
log.Printf("SoundTouch Web UI starting on http://%s", addr)
|
||||
|
||||
@@ -53,3 +90,126 @@ 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,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,19 +55,22 @@ 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 Web</title>
|
||||
<title>SoundTouch Control Center</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">SPA Content</div>
|
||||
@@ -97,7 +100,7 @@ func TestSPARouting(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAPIEndpoints(t *testing.T) {
|
||||
app := soundtouchweb.NewWebApp()
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -124,7 +127,7 @@ func TestAPIEndpoints(t *testing.T) {
|
||||
name: "device API with ID",
|
||||
path: "/api/device/test-device",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
expectedStatus: http.StatusNotFound, // Device won't exist in test
|
||||
expectedJSON: true,
|
||||
},
|
||||
}
|
||||
@@ -157,6 +160,7 @@ 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)
|
||||
@@ -167,7 +171,7 @@ func TestAPIEndpoints(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAPIResponseFormat(t *testing.T) {
|
||||
app := soundtouchweb.NewWebApp()
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/devices", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -179,6 +183,7 @@ 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)
|
||||
}
|
||||
@@ -187,6 +192,7 @@ 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)
|
||||
@@ -198,7 +204,7 @@ func TestAPIResponseFormat(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestControlAPIValidation(t *testing.T) {
|
||||
app := soundtouchweb.NewWebApp()
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -243,6 +249,7 @@ 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"},
|
||||
@@ -271,6 +278,7 @@ 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)
|
||||
@@ -293,8 +301,9 @@ func TestControlAPIValidation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWebSocketUpgrade(t *testing.T) {
|
||||
app := soundtouchweb.NewWebApp()
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
// Test WebSocket upgrade request
|
||||
req := httptest.NewRequest("GET", "/ws", nil)
|
||||
req.Header.Set("Connection", "upgrade")
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
@@ -303,11 +312,16 @@ 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 := soundtouchweb.NewWebApp()
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
endpoints := []string{
|
||||
"/api/devices",
|
||||
@@ -330,16 +344,19 @@ 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
|
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,200 @@
|
||||
<!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,5 +1,4 @@
|
||||
accounts/
|
||||
backend/
|
||||
certs/
|
||||
default/
|
||||
dns/
|
||||
|
||||
@@ -61,6 +61,8 @@
|
||||
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
|
||||
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
|
||||
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
|
||||
* [Telnet (Port 17000) Migration Method](analysis/TELNET-MIGRATION-METHOD.md)
|
||||
* [Telnet Command Reference](analysis/TELNET-COMMAND-REFERENCE.md)
|
||||
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
|
||||
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
|
||||
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
To enable offline operation or use custom services like **SoundCork** or **ÜberBöse API**, SoundTouch devices must be redirected from Bose's official cloud endpoints to a local or custom server. This document outlines the three known methods to achieve this, gathered from community reverse-engineering efforts in the **SoundCork** and **ÜberBöse API** projects.
|
||||
|
||||
> A fourth, **SSH-free** path — driving the device's diagnostic shell on TCP port 17000 — is being added as a peer to the XML and DNS methods. See **[TELNET-MIGRATION-METHOD.md](TELNET-MIGRATION-METHOD.md)** for the use cases, community findings, and feasibility analysis. The `/etc/hosts` method documented below is now deprecated and will not be exposed in the web UI.
|
||||
|
||||
## Overview of Redirection Targets
|
||||
|
||||
SoundTouch devices primarily communicate with the following domains:
|
||||
@@ -32,19 +34,25 @@ The most robust and granular method involves modifying the device's private conf
|
||||
Requires SSH access to the device.
|
||||
```xml
|
||||
<SoundTouchSdkPrivateCfg>
|
||||
<margeServerUrl>http://192.168.1.10:8000/marge</margeServerUrl>
|
||||
<margeServerUrl>http://192.168.1.10:8000</margeServerUrl>
|
||||
<statsServerUrl>http://192.168.1.10:8000</statsServerUrl>
|
||||
<swUpdateUrl>http://192.168.1.10:8000/updates/soundtouch</swUpdateUrl>
|
||||
<bmxRegistryUrl>http://192.168.1.10:8000/bmx/registry/v1/services</bmxRegistryUrl>
|
||||
</SoundTouchSdkPrivateCfg>
|
||||
```
|
||||
|
||||
> **Note on `margeServerUrl`** — `soundtouch-service` mounts the marge endpoints
|
||||
> at the **root** of port 8000, so the URL has no `/marge` suffix.
|
||||
> [`deborahgu/soundcork`](https://github.com/deborahgu/soundcork) routes marge
|
||||
> under a `/marge` sub-path, so users redirecting to soundcork must append it
|
||||
> (`http://192.168.1.10:8000/marge`).
|
||||
|
||||
### Pros & Cons
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Granular Control**: Redirect specific services while leaving others (e.g., updates) intact. | **Requires SSH**: Must have root/SSH access to the device. |
|
||||
| **Persistent**: Survives software updates (usually). | **Syntax Sensitive**: Errors in XML can cause boot issues or service failures. |
|
||||
| **Native**: Uses the device's built-in configuration mechanism. | |
|
||||
| Pros | Cons |
|
||||
|:----------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------|
|
||||
| **Granular Control**: Redirect specific services while leaving others (e.g., updates) intact. | **Requires SSH**: Must have root/SSH access to the device. |
|
||||
| **Persistent**: Survives software updates (usually). | **Syntax Sensitive**: Errors in XML can cause boot issues or service failures. |
|
||||
| **Native**: Uses the device's built-in configuration mechanism. | |
|
||||
|
||||
---
|
||||
|
||||
@@ -66,11 +74,11 @@ Requires SSH access. Add entries for the target domains:
|
||||
```
|
||||
|
||||
### Pros & Cons
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Simple**: Easy to understand and implement. | **Requires SSH**: Must have root access. |
|
||||
| Pros | Cons |
|
||||
|:--------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Simple**: Easy to understand and implement. | **Requires SSH**: Must have root access. |
|
||||
| **Universal**: Affects all processes on the device attempting to reach those domains. | **HTTPS Issues**: Redirecting HTTPS domains to a local IP will cause SSL certificate errors unless the device is patched to skip verification or trust a custom CA. |
|
||||
| | **Brittle**: Some firmware versions may overwrite `/etc/hosts` on reboot. |
|
||||
| | **Brittle**: Some firmware versions may overwrite `/etc/hosts` on reboot. |
|
||||
|
||||
---
|
||||
|
||||
@@ -104,12 +112,12 @@ sed "s#\^https:....bose.\+apigee..net..#http[aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
4. Restore execution permissions and reboot.
|
||||
|
||||
### Pros & Cons
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Bypass Config**: Works even if the firmware ignores XML settings. | **High Risk**: Modifying binaries can lead to permanent bricks or boot loops. |
|
||||
| Pros | Cons |
|
||||
|:------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------|
|
||||
| **Bypass Config**: Works even if the firmware ignores XML settings. | **High Risk**: Modifying binaries can lead to permanent bricks or boot loops. |
|
||||
| **Hardcoded Redirects**: Can catch URLs that aren't exposed in configuration files. | **Length Constraint**: Custom URLs must fit within the space of the original strings. |
|
||||
| | **Firmware Specific**: Patches must be reapplied after every software update. |
|
||||
| | **Complexity**: Requires understanding of binary structures and potential checksums. |
|
||||
| | **Firmware Specific**: Patches must be reapplied after every software update. |
|
||||
| | **Complexity**: Requires understanding of binary structures and potential checksums. |
|
||||
|
||||
---
|
||||
|
||||
@@ -117,11 +125,11 @@ sed "s#\^https:....bose.\+apigee..net..#http[aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
|
||||
### Summary Table
|
||||
|
||||
| Method | Primary Use Case | Ease | Safety | Persistence | Granularity |
|
||||
| :--- | :--- | :---: | :---: | :---: | :---: |
|
||||
| **XML Config** | Logical service redirection | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| **`/etc/hosts`** | Quick global DNS override | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
|
||||
| **Binary Patch** | Bypassing hardcoded checks | ⭐ | ⭐ | ⭐ | ⭐⭐⭐ |
|
||||
| Method | Primary Use Case | Ease | Safety | Persistence | Granularity |
|
||||
|:-----------------|:----------------------------|:-----:|:------:|:-----------:|:-----------:|
|
||||
| **XML Config** | Logical service redirection | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| **`/etc/hosts`** | Quick global DNS override | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
|
||||
| **Binary Patch** | Bypassing hardcoded checks | ⭐ | ⭐ | ⭐ | ⭐⭐⭐ |
|
||||
|
||||
---
|
||||
|
||||
@@ -176,9 +184,10 @@ As suggested by community members, you can configure the device to trust your ow
|
||||
- **Method B (Symlinks)**: Add the certificate to `/etc/ssl/certs/` and create a hash symlink using `c_rehash` (if available) or manual mapping.
|
||||
|
||||
**Pros & Cons**:
|
||||
| Pros | Cons |
|
||||
| :--- | :--- |
|
||||
| **Secure**: Maintains end-to-end encryption. | **Requires SSH**: Must have root access to modify the trust store. |
|
||||
|
||||
| Pros | Cons |
|
||||
|:-------------------------------------------------------|:-----------------------------------------------------------------------|
|
||||
| **Secure**: Maintains end-to-end encryption. | **Requires SSH**: Must have root access to modify the trust store. |
|
||||
| **Clean**: No binary patching required for SSL bypass. | **Update Risk**: Firmware updates might overwrite the `ca-bundle.crt`. |
|
||||
|
||||
### Option 2: SSL Verification Bypass
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
# Bose SoundTouch Telnet (Port 17000) Command Reference
|
||||
|
||||
A consolidated reference for the diagnostic shell that listens on TCP port
|
||||
17000 across the SoundTouch line. Compiled from multiple community sources
|
||||
to give a single map of what's been observed in the wild — useful both for
|
||||
implementing automation against it (see
|
||||
[TELNET-MIGRATION-METHOD.md](TELNET-MIGRATION-METHOD.md)) and for manual
|
||||
recovery / WiFi setup.
|
||||
|
||||
> **Important caveat.** The command set is firmware-dependent. Anything that
|
||||
> existed in firmware 1.x–7.x (`flarn2006`'s era) was progressively trimmed;
|
||||
> some commands listed here have been removed on firmware 27.x. Where a
|
||||
> command's availability is known to vary, the **Availability** column says so.
|
||||
|
||||
## Sources
|
||||
|
||||
| # | Source | Era / focus |
|
||||
|----|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| S1 | [flarn2006: "Hacking the Bose SoundTouch and its Linux insides"](https://flarn2006.blogspot.com/2014/09/hacking-bose-soundtouch-and-its-linux.html) (2014) | Firmware 1.x–7.x; root shell discovery, codenames |
|
||||
| S2 | [Sam Hobbs: "Connect Bose SoundTouch 10 to WiFi using Linux Telnet"](https://samhobbs.co.uk/2016/01/connect-bose-soundtouch-10-wifi-using-linux-telnet) (2016) | ST 10 setup mode; `network`/`sys` families |
|
||||
| S3 | [izndgroup: "Connect Bose SoundTouch 10 to WiFi"](https://technical.izndgroup.com/2021/02/connect-bose-soundtouch-10-to-wifi.html) (2021) | Reissue of S2 with later-firmware notes |
|
||||
| S4 | [sijeffrey/SoundTouch — `bose` script](https://github.com/sijeffrey/SoundTouch/blob/master/bose) (2017) | `nc`-based remote-control script using `sys`/`ws` |
|
||||
| S5 | [r/bose "SoundTouch telnet probing"](https://www.reddit.com/r/bose/comments/1o5zkym/soundtouch_telnet_probing/) | Recent (post-EOS) probing on ST 10 firmware `27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29`; comments mirrored in [#221](https://github.com/gesellix/Bose-SoundTouch/issues/221) |
|
||||
| S6 | Issue [#221](https://github.com/gesellix/Bose-SoundTouch/issues/221), [#236](https://github.com/gesellix/Bose-SoundTouch/issues/236), [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141) | The migration commands we already implement |
|
||||
|
||||
---
|
||||
|
||||
## Connecting to the shell
|
||||
|
||||
### From an already-on-network device
|
||||
|
||||
The shell binds to TCP port 17000 on every device family observed (ST 10/20/300, Wave III/IV, ST 520, SA-5 — see §"Firmware era notes" for caveats). No authentication.
|
||||
|
||||
```bash
|
||||
# A no-op probe just to verify reach.
|
||||
echo '' | nc -w 2 <device-ip> 17000
|
||||
|
||||
# Or interactively — works the same.
|
||||
telnet <device-ip> 17000
|
||||
```
|
||||
|
||||
The `bose` script (S4) goes one level lower and writes commands directly to a `/dev/tcp/<ip>/17000` redirection target instead of using `nc`. That's the same wire protocol with no library between.
|
||||
|
||||
### From a factory-fresh / WiFi-less device
|
||||
|
||||
Per S2/S3 — newer firmware may have closed this on some models:
|
||||
|
||||
1. **Enter setup mode.** Press and hold key **2** + **volume down** for 5 seconds until the WiFi LED turns amber.
|
||||
2. **Connect your laptop to the speaker's open access point.** The speaker becomes its own AP.
|
||||
3. **Telnet to `192.0.2.1` on port 17000.**
|
||||
|
||||
Once you've added a WiFi profile (see `network wifi profiles add` below) the speaker reboots into station mode and the AP goes away.
|
||||
|
||||
### Hardware key combinations on the device itself
|
||||
|
||||
| Combo | Effect | Source |
|
||||
|-------------------|------------------------------------------|--------|
|
||||
| `1` + volume-down | Factory reset | S2, S3 |
|
||||
| `2` + volume-down | Setup mode (open WiFi AP at `192.0.2.1`) | S2, S3 |
|
||||
| `3` + volume-down | Toggle WiFi / Bluetooth | S2, S3 |
|
||||
| `4` + volume-down | Check for software updates | S2, S3 |
|
||||
|
||||
---
|
||||
|
||||
## The `network` family — WiFi & interfaces
|
||||
|
||||
| Command | Purpose | Availability | Source |
|
||||
|------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|----------------------------|--------|
|
||||
| `network wifi status` | Current SSID, state (e.g. `WIFI_STATION_CONNECTED`), signal strength. Returns XML-like `<WiFiStatus SSID="…" state="…">`. | Wide | S2, S3 |
|
||||
| `network wifi scan [<maxresults>]` | Site survey. | Wide | S2 |
|
||||
| `network wifi profiles info` | Lists stored WiFi profiles (passphrases shown encrypted). | Wide | S2, S3 |
|
||||
| `network wifi profiles add <ssid> <security> [<password>]` | Adds a WiFi network. `<security>` ∈ `none` \| `wep` \| `wpa_or_wpa2`. | Wide; setup-mode workhorse | S2, S3 |
|
||||
| `network wifi profiles clear` | Wipes all stored profiles. | Wide | S2 |
|
||||
| `network status` | All interfaces and IP addresses. | Wide | S2, S3 |
|
||||
| `network dhcp` | Current DHCP interface info. | Wide | S2 |
|
||||
| `network mode auto\|wifioff\|wifisetup` | Switch radio / setup-AP state. | Wide | S2 |
|
||||
|
||||
**Example session — adding a network from setup mode (S3):**
|
||||
|
||||
```
|
||||
network wifi profiles add foobarHub wpa_or_wpa2 topsecret
|
||||
```
|
||||
|
||||
The speaker stores the profile, drops the setup AP, and reboots into station mode.
|
||||
|
||||
---
|
||||
|
||||
## The `key` family — front-panel button emulation
|
||||
|
||||
Each `key …` command emulates a press of a physical button on the speaker
|
||||
or remote. Confirmed working on ST 10 / FW `27.0.6.46330.5043500` (S5);
|
||||
also visible on the ST 20/300/Wave captures in #221. Different from the
|
||||
`sys presetkey N p` form (S4) — the `key prefix_N` shape on FW 27 is what
|
||||
the device's own remote sends.
|
||||
|
||||
| Command | Effect | Source |
|
||||
|---------------------------------|---------------------------------------------------------------------------------------|--------|
|
||||
| `key prefix_1` … `key prefix_6` | Triggers preset 1–6 (same as a remote preset press). | S5 |
|
||||
| `key play` | Begin / resume playback. | S5 |
|
||||
| `key pause` | Pause playback. | S5 |
|
||||
| `key stop` | Stop playback (does **not** terminate the underlying stream). | S5 |
|
||||
| `key prev` | Restart current song / previous track. | S5 |
|
||||
| `key next` | Next track. | S5 |
|
||||
| `key aux` | Toggle Bluetooth / AUX input. | S5 |
|
||||
| `key power` | Echoes "OK" but no observable effect on FW 27.x — possibly handled at a higher layer. | S5 |
|
||||
|
||||
The S4 `bose` script's `sys presetkey N p` form still works, but `key prefix_N` is shorter and matches what the remote already does on FW 27.x.
|
||||
|
||||
---
|
||||
|
||||
## The `sys` family — system control & service URLs
|
||||
|
||||
The `sys` family is the one our migration uses (see §"What we use during migration"). Two distinct sub-syntaxes coexist:
|
||||
|
||||
- **Single-token verbs:** `sys reboot`, `sys volume`, `sys power`, etc.
|
||||
- **`sys configuration <key> <value>` setters** that modify persisted runtime configuration. Used for the four service URLs (margeServerUrl, statsServerUrl, swUpdateUrl, bmxRegistryUrl).
|
||||
|
||||
| Command | Purpose | Availability | Source |
|
||||
|---------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------|------------|
|
||||
| `sys reboot` | Restart the device. | Wide | S2, S6 |
|
||||
| `sys factorydefault` | Reset to factory defaults. | Wide | S1, S2 |
|
||||
| `sys ver` | Firmware version string, e.g. `BoseApp version: 27.0.6.46330.5043500 …`. | Wide; confirmed on FW 27.x | S1, S5 |
|
||||
| `sys power` | Toggle power. Confirmed working on older firmware via S2/S4; on FW 27.x ST 10 the response is `OK` but with **no observable effect** — power state may be controlled elsewhere on that build. | Varies | S2, S4, S5 |
|
||||
| `sys playpause` | Toggle playback. | Wide | S2 |
|
||||
| `sys stop`, `sys pause` | Accepted (return `OK`) but **no observable effect** on FW 27.x ST 10 — the working stop/pause path on that firmware is `key stop` / `key pause`. | Wide / no-op | S5 |
|
||||
| `sys volume` | Print current volume. The S4 script parses the 5th token of the first line. | Wide | S2, S4, S5 |
|
||||
| `sys volume <int>` | Set absolute volume to `<int>`. | Wide | S5 |
|
||||
| `sys volume up <n>` / `sys volume down <n>` | Adjust volume by `<n>` (steps, not dB). | Wide | S4 |
|
||||
| `sys volume <value> updateDisplay` | Set absolute volume and update the front-panel display. | Wide | S2 |
|
||||
| `sys presetkey <1-6> p` | Trigger a preset (`p` = press). Older shape of `key prefix_<N>`. | Wide | S4 |
|
||||
| `sys timeout inactivity disable` (or `off`) | Stop the auto-shutoff timer. May need to be sent twice. | Wide | S1, S2 |
|
||||
| `sys configuration` (no args) | Returns the usage hint `sys configuration <XMLTag> <XMLValue>` — confirms the underlying setter is XML-tag-keyed. | FW 27.x | S5 |
|
||||
| `sys configuration bmxRegistryUrl <url>` | Set the Bose Media eXchange registry URL. | Wide; **migration** | S6 |
|
||||
| `sys configuration statsServerUrl <url>` | Set the telemetry/stats endpoint. | Wide; **migration** | S6 |
|
||||
| `sys configuration margeServerUrl <url>` | Set the marge / streaming endpoint. | Wide; **migration** | S6 |
|
||||
| `sys configuration swUpdateUrl <url>` | Set the software-update endpoint. | Wide; **migration** | S6 |
|
||||
|
||||
Each `sys configuration` setter is reported by users to return `OK` on success. Wait for that token between commands (S6, `foob61451`).
|
||||
|
||||
---
|
||||
|
||||
## The `envswitch` family — parallel persistence layer
|
||||
|
||||
`envswitch` writes to a separate, lower-level persistence store that **wins on next reboot** if the corresponding `sys configuration` value differs. So our migration writes both — see TELNET-MIGRATION-METHOD.md §2.1.
|
||||
|
||||
| Command | Purpose | Source |
|
||||
|---------------------------------------------------|-----------------------------------------------------------------------------------------------|---------|
|
||||
| `envswitch boseurls set <margeUrl> <swUpdateUrl>` | Persist the marge and update URLs. **Two arguments**, in that order. | S6 |
|
||||
| `envswitch accountid set <numeric-id>` | Equivalent to the HTTP `/setMargeAccount` POST. Used as fallback in our `PairAccount` helper. | S6 |
|
||||
| `envswitch accountid get` | Plausible by symmetry but **not yet confirmed** across firmwares; we probe it best-effort. | (probe) |
|
||||
|
||||
---
|
||||
|
||||
## The `getpdo` family — read persisted configuration
|
||||
|
||||
`getpdo <selector>` prints the contents of a persisted-data-object. We use it as the verification step after writing URLs.
|
||||
|
||||
| Selector | Purpose | Source |
|
||||
|-------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|
|
||||
| `getpdo CurrentSystemConfiguration` | Echoes the resolved URL set, including margeServerUrl/bmxRegistryUrl/statsServerUrl/swUpdateUrl. We grep our targetURL out of this to confirm a successful migration. | S6 |
|
||||
|
||||
---
|
||||
|
||||
## The `scm` family — service control
|
||||
|
||||
`scm` (System Control / Module manager) lets you inspect and restart internal services.
|
||||
|
||||
| Command | Purpose | Availability | Source |
|
||||
|-------------------------|------------------------------------------------------------------------------------------|----------------|------------------------------------------------------------------------------|
|
||||
| `scm list` | List running services. | Older firmware | S1 |
|
||||
| `scm restart <service>` | Restart a service by name. | Older firmware | S1 |
|
||||
| `scm uboot_ver` | Print bootloader version (`U-Boot 2013.01.01-…`). Confirmed working on SA-5 with FW 9.x. | Older firmware | [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141) |
|
||||
|
||||
---
|
||||
|
||||
## Shell-unlock commands
|
||||
|
||||
These are the commands that gated SSH access on older firmware. Both have been progressively removed; on FW 27.x they generally do nothing useful.
|
||||
|
||||
| Command | Purpose | Availability | Source |
|
||||
|-----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------|----------------------------------------------------------------------------------|
|
||||
| `remote_services on` | Enable SSH on port 22. Volatile (re-enter after reboot). Response: `remote services on`. **Removed in FW 7.x+**. | Old | S1 |
|
||||
| `local_services on` | Alternative enablement; works on some firmware where `remote_services` was removed. SA-5 FW 9.x reports `local services on`, but this alone does not appear to grant SSH on most models. | Old, hit-or-miss | S1, [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141) |
|
||||
| `demo enter` / `mode enter` | Unlocks demo / button-test mode (used historically to recover bricked units). | Old | S1 |
|
||||
|
||||
---
|
||||
|
||||
## The `ws` and `swupdate` families
|
||||
|
||||
| Command | Purpose | Availability | Source |
|
||||
|------------------|---------------------------------------------------------------------------------------------------------|--------------|--------|
|
||||
| `ws getpresets` | Returns an XML list of presets — the S4 script parses the `<itemName>…<text>…` blocks to extract names. | Wide | S4 |
|
||||
| `swupdate abort` | Cancel a software update in progress. | Wide | S1 |
|
||||
|
||||
---
|
||||
|
||||
## `help`
|
||||
|
||||
Lists the commands available on the running firmware. **Frequently removed** on later firmware — returns `Command not found` on FW 27.x in many of the captures we have. Still worth probing once during preflight: a successful response is a quick way to enumerate what this specific build supports without trial-and-error.
|
||||
|
||||
---
|
||||
|
||||
## Device codenames (S1)
|
||||
|
||||
These show up in `getpdo`, `network status`, and SSH-side hostnames. Useful for matching captures to hardware.
|
||||
|
||||
| Codename | Hardware |
|
||||
|----------|------------------------------------------------|
|
||||
| `lisa` | Adapter (older speakers running Bose firmware) |
|
||||
| `spotty` | SoundTouch 20 |
|
||||
| `rhino` | SoundTouch 10 |
|
||||
| `mojo` | SoundTouch 30 |
|
||||
| `taigan` | SoundTouch Portable |
|
||||
|
||||
---
|
||||
|
||||
## Firmware era notes
|
||||
|
||||
- **Firmware 1.x–7.x** (S1 era): everything — `help`, `remote_services on`, full `scm`, and an in-shell login prompt. `flarn2006` documents the original Linux insides.
|
||||
- **Firmware 8.x–14.x** (S2 era): `remote_services on` removed; `network`, `sys`, `envswitch`, `getpdo` still present. `local_services on` works on some Wave/SA-5 models.
|
||||
- **Firmware 27.x** (S5/S6 era — the long-lived "frozen" build that survived through EOS): `help`, `remote_services on`, and `sys ver` removed in some builds; `sys configuration …` and `envswitch …` confirmed working on ST 10, ST 20, ST 300, Wave III, Wave IV. **This is the firmware our migration targets**. The Portable on more recent firmware drops further commands and is the hardest target.
|
||||
|
||||
S5 enumerated the **top-level command roots** that don't return "Command not found" on a vanilla ST 10 (`rhino`) running `27.0.6.46330.5043500`:
|
||||
|
||||
```
|
||||
key
|
||||
net
|
||||
sys
|
||||
getpdo
|
||||
```
|
||||
|
||||
Notably absent from that probe: `network`, `envswitch`, `scm`, `ws`, `swupdate`, `remote_services`, `local_services`, `demo`, `mode`, `help`. **However**, other captures on the same firmware family (S6, ST 20 / Wave III / Wave IV) accept `envswitch …`, suggesting either per-model variation in the shipped command table or an SSH/role gate the S5 author didn't trip. Implementations that use `envswitch` should treat its absence as a recoverable preflight outcome (we already do).
|
||||
|
||||
`net` is observed as a valid root by S5 but its sub-commands aren't enumerated; it may be a shorthand alias for `network` on FW 27.x ST 10.
|
||||
|
||||
---
|
||||
|
||||
## What we use during migration
|
||||
|
||||
For quick reference, the exact sequence our `pkg/service/setup.migrateViaTelnet` issues, all on the same connection, in this order:
|
||||
|
||||
```
|
||||
sys configuration bmxRegistryUrl <serverURL>/bmx/registry/v1/services
|
||||
sys configuration statsServerUrl <serverURL>
|
||||
sys configuration margeServerUrl <serverURL>
|
||||
sys configuration swUpdateUrl <serverURL>/updates/soundtouch
|
||||
envswitch boseurls set <serverURL> <serverURL>/updates/soundtouch
|
||||
getpdo CurrentSystemConfiguration
|
||||
```
|
||||
|
||||
Plus, when pairing a fresh device whose `:8090/setMargeAccount` is missing or wedged, the helper falls back to:
|
||||
|
||||
```
|
||||
envswitch accountid set <7-digit-id>
|
||||
```
|
||||
|
||||
Reboot is **not** part of these sequences — it stays a user-initiated action via the existing reboot button, which now accepts `?method=telnet|ssh` and sends `sys reboot` when telnet is picked.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope here, but worth recording
|
||||
|
||||
- **Setup-mode WiFi onboarding via 192.0.2.1.** The community uses this to add a fresh device to a network without the Bose app. Our `soundtouch-service` does not currently automate this, but `network wifi profiles add` is the entry point if we ever do.
|
||||
- **Direct preset / playback control via `sys`.** The S4 `bose` script demonstrates a viable headless remote-control path that does not need our marge emulation at all. Useful as a fallback for tooling on devices that refuse to talk to any cloud.
|
||||
- **`scm restart <service>`.** Not used today, but a possible recovery primitive on older firmware where a stuck service blocks streaming.
|
||||
@@ -0,0 +1,465 @@
|
||||
# Telnet (Port 17000) Migration Method — Analysis
|
||||
|
||||
This document captures the use cases, community findings, and feasibility analysis
|
||||
for adding a **Telnet/port 17000** migration path to `soundtouch-service` as a
|
||||
peer of the existing XML and DNS-based methods. The `/etc/hosts` method stays
|
||||
deprecated and is intentionally kept off the visible UI options.
|
||||
|
||||
> **Sources** — community discussion synthesised from
|
||||
> [gesellix/Bose-SoundTouch#221](https://github.com/gesellix/Bose-SoundTouch/issues/221),
|
||||
> [gesellix/Bose-SoundTouch#236](https://github.com/gesellix/Bose-SoundTouch/issues/236),
|
||||
> [scheilch/opencloudtouch#167](https://github.com/scheilch/opencloudtouch/issues/167),
|
||||
> [deborahgu/soundcork#228](https://github.com/deborahgu/soundcork/issues/228),
|
||||
> [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141),
|
||||
> the post-EOS walkthrough PDF in `docs/`,
|
||||
> [Bose SoundTouch Telnet Probing thread](https://www.reddit.com/r/bose/comments/1o5zkym/soundtouch_telnet_probing/),
|
||||
> and [flarn2006's blog post on hacking SoundTouch](https://flarn2006.blogspot.com/2014/09/hacking-bose-soundtouch-and-its-linux.html).
|
||||
|
||||
---
|
||||
|
||||
## 1. Why a third method is needed
|
||||
|
||||
The two currently shipped methods both have hard preconditions that block real
|
||||
users:
|
||||
|
||||
| Method | Preconditions | Failure modes seen in the wild |
|
||||
|-----------------------------------------|--------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **XML** (`SoundTouchSdkPrivateCfg.xml`) | SSH/root access — needs `remote_services` USB unlock first | Some firmware revisions (e.g. SA-5, ST520, latest ST Portable) refuse the USB unlock entirely; `remote_services on` was removed from the telnet command set in firmware 7.x and later. |
|
||||
| **DNS** (`resolv.conf` priority hook) | SSH/root access; service must own port 53 on the LAN gateway | Won't fit users behind ISP routers they can't reconfigure; still requires the device to be SSH-reachable to write the hook. |
|
||||
|
||||
The community has demonstrated a **third path that needs no SSH at all**:
|
||||
the device's built-in **diagnostic Telnet shell on TCP port 17000** accepts
|
||||
configuration commands that change exactly the same fields the XML method would.
|
||||
|
||||
### 1.1 Confirmed user reports (firmware 27.0.6.46330.5043500 unless noted)
|
||||
|
||||
| Reporter | Hardware | Outcome |
|
||||
|--------------------|---------------------------|-------------------------------------------------------------------------------------------------------------|
|
||||
| `foob61451` (#221) | ST 10, ST 20 (non-rooted) | All four URLs persisted via `sys configuration …`; `envswitch boseurls set …` survived `sys reboot`. |
|
||||
| `bveenker` (#221) | Wave III | URLs accepted; presets work after pairing via `/setMargeAccount` (see §3). |
|
||||
| `stephan48` (#221) | Wave IV | Telnet:1700 + USB stick `remote_services` did **not** work; **port 17000 telnet** worked for all four URLs. |
|
||||
| `mcdona1d` (#141) | ST 20, ST 300 | Confirmed working with `sys configuration …` + `envswitch …` + `sys reboot`. |
|
||||
| `TJGigs` (#228) | ST 20 ×2, ST 10 | Wraps telnet:17000 into an admin "Smart Inject" tool; uses `sys reboot` over telnet to nudge devices. |
|
||||
|
||||
So the method is plausible across **at least ST 10/20/300 and Wave III/IV** on
|
||||
the most common firmware that survived the EOS cut, **without the USB unlock
|
||||
dance** that newer firmware refuses.
|
||||
|
||||
---
|
||||
|
||||
## 2. The Telnet:17000 command set we rely on
|
||||
|
||||
> For a broader catalogue of every telnet command the community has documented
|
||||
> across firmware eras (the `key`, `network`, `sys`, `envswitch`, `getpdo`,
|
||||
> `scm`, `ws`, `swupdate`, and shell-unlock families), see
|
||||
> **[TELNET-COMMAND-REFERENCE.md](TELNET-COMMAND-REFERENCE.md)**. This
|
||||
> section only lists the subset our migration actually drives.
|
||||
|
||||
|
||||
### 2.1 URL configuration (the migration payload)
|
||||
|
||||
The sequence we send for `soundtouch-service` (community-validated in #221, #141):
|
||||
|
||||
```
|
||||
sys configuration bmxRegistryUrl http://<service-host>:8000/bmx/registry/v1/services
|
||||
sys configuration statsServerUrl http://<service-host>:8000
|
||||
sys configuration margeServerUrl http://<service-host>:8000
|
||||
sys configuration swUpdateUrl http://<service-host>:8000/updates/soundtouch
|
||||
envswitch boseurls set http://<service-host>:8000 http://<service-host>:8000/updates/soundtouch
|
||||
getpdo CurrentSystemConfiguration
|
||||
```
|
||||
|
||||
`sys reboot` is **not** part of this sequence. The migration flow only writes
|
||||
configuration — the reboot is user-initiated via the existing reboot button in
|
||||
the web UI, mirroring what XML/DNS migration already does. See §6.2 for how
|
||||
that button gains a `?method=ssh|telnet` selector.
|
||||
|
||||
Three important details from the discussion:
|
||||
|
||||
1. **`sys configuration` alone is not enough.** `stephan48` reported that
|
||||
without the `envswitch boseurls set …` line his typo in `bmxRegistryUrl` was
|
||||
silently restored on reboot — i.e. there is a parallel "envswitch" persistence
|
||||
layer that wins on next boot if you don't also write to it. **We must always
|
||||
issue both.**
|
||||
2. **margeServerUrl path is bare for `soundtouch-service`.** We mount the marge
|
||||
endpoints at the **root** of port 8000, matching what the existing XML
|
||||
migration writes (`Manager.migrateViaXML` in `pkg/service/setup/setup.go`
|
||||
sets `MargeServerUrl: targetURL` without any suffix). Some community
|
||||
recipes appended `/marge` because they were targeting
|
||||
[`deborahgu/soundcork`](https://github.com/deborahgu/soundcork), which
|
||||
routes marge under that sub-path. **For our service: bare URL. For users
|
||||
redirecting to soundcork: append `/marge`** to both `margeServerUrl` and
|
||||
the first argument of `envswitch boseurls set`.
|
||||
3. **Each command must be sent one at a time, waiting for the device's `OK`
|
||||
response** before sending the next one (`foob61451`'s explicit warning).
|
||||
|
||||
### 2.2 Account pairing fallback
|
||||
|
||||
`envswitch accountid set <numeric-id>` was reported by `bveenker` (#221) as an
|
||||
in-band equivalent to the HTTP `/setMargeAccount` call, useful when the
|
||||
`/setMargeAccount` endpoint is missing on the firmware (see §3).
|
||||
|
||||
### 2.3 Probing / preflight
|
||||
|
||||
- A bare TCP connect to `<deviceIP>:17000` answers (no auth) on devices we care
|
||||
about.
|
||||
- Useful read-only verification command: `getpdo CurrentSystemConfiguration` —
|
||||
prints the URLs after the changes have been applied so we can verify before
|
||||
rebooting.
|
||||
- `sys reboot` is the trigger that re-reads both layers.
|
||||
|
||||
### 2.4 What Telnet:17000 cannot do
|
||||
|
||||
- It does **not** install a custom CA. So if a user wants HTTPS rather than HTTP
|
||||
redirection to our service (the DNS-method scenario, where `resolv.conf`
|
||||
redirection collides with the device's TLS validation unless our root CA is
|
||||
trusted on the device), telnet alone won't cover it. This is fine for our
|
||||
default flow, which uses plain `http://` URLs to the service's port 8000.
|
||||
- It does not give us a way to read or write `Sources.xml` (third-party
|
||||
account credentials) — that still requires SSH, but for a migration we don't
|
||||
actually need it.
|
||||
|
||||
---
|
||||
|
||||
## 3. The `/setMargeAccount` problem (issue #236, #228)
|
||||
|
||||
### 3.1 What it is
|
||||
|
||||
A factory-reset speaker has an empty `<margeAccountUUID/>` in `:8090/info`. The
|
||||
marge endpoints fail with 502 / unhandled until that field is populated, which
|
||||
is why several users (#221, #236) saw **everything except AUX** broken after
|
||||
migration:
|
||||
|
||||
```
|
||||
POST http://<deviceIP>:8090/setMargeAccount
|
||||
Content-Type: application/xml
|
||||
|
||||
<PairDeviceWithAccount>
|
||||
<accountId>1234567</accountId>
|
||||
<userAuthToken>soundcorkdoesntcare</userAuthToken>
|
||||
</PairDeviceWithAccount>
|
||||
```
|
||||
|
||||
The values are not validated by the local service, so any numeric `accountId`
|
||||
will work — soundcork's runbook (#228) literally calls the token
|
||||
`soundcorkdoesntcare` to make the point.
|
||||
|
||||
### 3.2 Why it's broken in practice
|
||||
|
||||
There are **three independent failure modes** observed:
|
||||
|
||||
| Symptom | Cause | Detection |
|
||||
|-----------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------|
|
||||
| Endpoint returns 404 / "not implemented" | Newer firmware (e.g. some BST20 Portable, latest ST Portable) drops the endpoint entirely. | `GET /supportedURLs` does **not** list `/setMargeAccount` in `<URL location="…"/>`. |
|
||||
| Endpoint hangs (no response / socket stays open) | "Broken state" the user explicitly called out — endpoint advertised, but handler is wedged. | Caller has to time out; we currently have no timeout, so the request appears to hang the migration UI indefinitely. |
|
||||
| `POST /marge/streaming/support/power_on` → 502 unhandled (#236) | Device keeps polling marge after migration but no `margeAccountUUID` was ever assigned, so all subsequent calls fail. | `:8090/info` shows `<margeAccountUUID/>` empty after reboot. |
|
||||
|
||||
### 3.3 Required handling
|
||||
|
||||
Per the user's brief, the migration logic must:
|
||||
|
||||
1. **Probe** `GET http://<deviceIP>:8090/supportedURLs` and check whether
|
||||
`/setMargeAccount` is in the list **before** trying to POST it.
|
||||
2. **Time-bound** the POST aggressively (e.g. ≤5s connect + ≤10s read) and treat
|
||||
anything over the budget as a failure rather than waiting indefinitely.
|
||||
3. On either failure mode, **fall back** to the telnet equivalent
|
||||
`envswitch accountid set <id>` over the same `pkg/telnet` connection used
|
||||
for the URL flip. Reboot stays a user-initiated action (§6.2).
|
||||
4. If telnet:17000 is **also** unreachable, surface a clear "your firmware does
|
||||
not support unattended pairing — please pair manually via the official Bose
|
||||
app *before* it goes EOS, or open SSH and use the XML method" error rather
|
||||
than leaving the device in a half-migrated state.
|
||||
|
||||
### 3.4 Where the `<id>` comes from
|
||||
|
||||
The device's current account ID is already discoverable through endpoints we
|
||||
control:
|
||||
|
||||
- **`GET :8090/info`** returns `<margeAccountUUID>…</margeAccountUUID>`. If it
|
||||
is non-empty the device is already paired — **reuse that ID**, do not
|
||||
reassign. Our local marge accepts any ID, so the existing one is fine.
|
||||
- If it is empty (factory reset), the user picks one in the UI:
|
||||
1. **Pick from existing accounts.** The setup UI lists IDs returned by
|
||||
`DataStore.ListAccounts()` so a user can re-attach a fresh device to an
|
||||
account that already has presets/recents/sources.
|
||||
2. **Enter manually.** Free-form text input, validated as **exactly 7
|
||||
numeric digits** (the format every Bose-cloud-issued ID has had in the
|
||||
captures we've seen, and the format the wider community uses in their
|
||||
recipes).
|
||||
3. **Randomize.** A "Generate" button that picks a 7-digit number and
|
||||
re-rolls if it collides with an existing account in the local datastore.
|
||||
- **Telnet read-back (best-effort).** `envswitch accountid get` is plausible by
|
||||
symmetry with `envswitch accountid set` (#221) but is not yet confirmed
|
||||
across firmwares. We will probe it during preflight; if it returns a value
|
||||
we cross-check it against `:8090/info` and warn on mismatch.
|
||||
|
||||
This means the user is never *forced* to invent a number — the common path is
|
||||
"the device already has an ID, reuse it" — and the manual/randomize controls
|
||||
only show up when the device is genuinely fresh.
|
||||
|
||||
---
|
||||
|
||||
## 4. Port 17000 availability
|
||||
|
||||
The diagnostic shell is gated by firmware build and product family. Anecdotally:
|
||||
|
||||
- ST 10 / ST 20 / ST 300 / Wave III / Wave IV on FW 27.0.6 → **open**.
|
||||
- SA-5 with FW 9.x → some commands present (`local_services on`) but
|
||||
**no `remote_services on`** and no SSH on FW 9.0.43.23466 (#141).
|
||||
- Modern firmware on some Portables → endpoint set has shrunk further.
|
||||
|
||||
Because of this, we cannot assume port 17000 is reachable. The migration flow
|
||||
must:
|
||||
|
||||
1. **Probe** with a TCP connect to `<deviceIP>:17000`, with a tight timeout
|
||||
(≤2s). A successful TCP handshake is necessary but not sufficient — some
|
||||
hardened firmware closes the port immediately.
|
||||
2. **Banner check.** After connecting, read whatever the device sends within
|
||||
~1s. The diagnostic shell prints a small banner (firmware-dependent); a
|
||||
blank read or an immediate close means we should treat it as "telnet not
|
||||
usable" and disable the option.
|
||||
3. **Capability check.** Issue a no-op like `getpdo CurrentSystemConfiguration`
|
||||
and look for any non-empty response. If the device replies "Command not
|
||||
found" we abort and suggest XML or DNS instead.
|
||||
4. **Surface state to the UI.** The migration form should grey out the Telnet
|
||||
option when the probe fails and show *why* (closed, banner missing,
|
||||
command rejected) instead of letting the user click into a dead end.
|
||||
|
||||
---
|
||||
|
||||
## 5. Implementation feasibility — Telnet client in Go
|
||||
|
||||
This is a feasibility check only; no code is written yet.
|
||||
|
||||
### 5.1 Protocol
|
||||
|
||||
"Telnet" on port 17000 is effectively a line-oriented plain-TCP shell. The
|
||||
device prints a small prompt (`->` in the SA-5 captures from #141) and reads
|
||||
newline-terminated commands. There is **no** real Telnet option negotiation
|
||||
(no `IAC`/`DO`/`WILL` exchanges visible in the wild captures), so we don't
|
||||
need `golang.org/x/crypto/ssh`-class machinery.
|
||||
|
||||
### 5.2 Standard-library only
|
||||
|
||||
A minimal client is just `net.DialTimeout("tcp", host+":17000", 2*time.Second)` +
|
||||
`bufio.Scanner` + `time.Time`-based deadlines on `Conn`. No third-party Telnet
|
||||
library is needed; `github.com/reiver/go-telnet` would be overkill and adds
|
||||
maintenance surface for no benefit. This matches the project's KISS principle
|
||||
in `docs/CLAUDE.md` §3.
|
||||
|
||||
### 5.3 Cross-platform compatibility
|
||||
|
||||
`net.Dial` over TCP works identically on Windows, macOS, Linux and (with
|
||||
limitations on listening) WASM. WASM-side: `soundtouch-service` runs server-side
|
||||
anyway, so this only matters for `soundtouch-cli`, where TCP dial works in any
|
||||
target other than browser-WASM — an acceptable carve-out documented separately.
|
||||
|
||||
### 5.4 Concurrency / safety
|
||||
|
||||
Each migration is a single goroutine driving one device. The client must:
|
||||
|
||||
- enforce per-command response deadlines so a wedged device cannot stall the
|
||||
migration UI (mirrors the `/setMargeAccount` requirement);
|
||||
- abort the rest of the sequence on the first non-`OK` response so we don't
|
||||
half-write configuration;
|
||||
- always close the socket on error.
|
||||
|
||||
### 5.5 Testing strategy
|
||||
|
||||
We can test without a real speaker by spinning up a `net.Listen("tcp", "127.0.0.1:0")`
|
||||
in the test, scripting it to consume our commands and emit canned `OK`/error
|
||||
responses. That gives us deterministic coverage for:
|
||||
|
||||
- happy path (all four URLs accepted),
|
||||
- single-command failure → sequence aborts, no further commands sent,
|
||||
- "command not found" on `envswitch …` → fallback path exercised,
|
||||
- TCP closed mid-stream → migration aborts cleanly,
|
||||
- read deadline triggers when the device hangs (the broken-state simulation).
|
||||
|
||||
The repo already follows the "real device responses preferred, mock servers
|
||||
otherwise" rule (see `docs/CLAUDE.md` §1, §8). The tests above are the mock-server
|
||||
half of that pattern.
|
||||
|
||||
### 5.6 Where it lives
|
||||
|
||||
The protocol client is **a standalone package**, not buried inside
|
||||
`pkg/service/setup`, so it can be reused from CLI tools, future setup wizards,
|
||||
and tests without dragging the migration manager in:
|
||||
|
||||
```
|
||||
pkg/telnet/ # NEW reusable package
|
||||
client.go # Dial / SendCommand / Probe / Close
|
||||
client_test.go # mock-server tests against a net.Listen
|
||||
|
||||
pkg/service/setup/
|
||||
telnet_migration.go # NEW thin wrapper that imports pkg/telnet
|
||||
# and runs the URL config sequence
|
||||
marge_pairing.go # NEW /setMargeAccount probe + post + telnet
|
||||
# `envswitch accountid set` fallback
|
||||
setup.go # add MigrationMethodTelnet const + case
|
||||
```
|
||||
|
||||
UI plumbing is `pkg/service/handlers/web/index.html` (option list) and
|
||||
`pkg/service/handlers/web/js/script.js` (`toggleMigrationMethod()`). The
|
||||
deprecated `hosts` option is already hidden from the dropdown when we ship
|
||||
this; we just add a `telnet` option next to `xml`/`resolv`.
|
||||
|
||||
### 5.7 Verdict
|
||||
|
||||
**Feasible and small.** Estimated scope: ~200 lines of client code in
|
||||
`pkg/telnet`, ~300 lines of tests, plus a `MigrationMethodTelnet` branch in
|
||||
`Manager.MigrateSpeaker`, plus the preflight probe described in §4 and the
|
||||
`/setMargeAccount` guarding described in §3.
|
||||
|
||||
---
|
||||
|
||||
## 6. Decisions made (was: open questions)
|
||||
|
||||
1. **Account-ID generation.** Resolved — see §3.4. The migration form reads
|
||||
`:8090/info` first; if `margeAccountUUID` is non-empty it is reused.
|
||||
Otherwise the UI offers (a) pick from `DataStore.ListAccounts()`,
|
||||
(b) manual entry validated as 7 numeric digits, (c) a "Generate" button
|
||||
that randomizes a 7-digit number and re-rolls on collision.
|
||||
2. **Reboot policy.** Migration writes configuration only — it does **not**
|
||||
issue `sys reboot` itself. Reboot stays user-initiated via the existing
|
||||
reboot button in the web UI, the same way XML/DNS migration already works.
|
||||
That button's endpoint (`POST /setup/reboot/{deviceId}`,
|
||||
`Manager.Reboot(deviceIP)`) gains an optional `?method=ssh|telnet` query
|
||||
parameter; default stays `ssh` so existing behavior is preserved. The
|
||||
button itself uses a plain `confirm()` dialog before firing.
|
||||
3. **CA / HTTPS story.** Telnet has no way to install a custom CA. Documented
|
||||
as an explicit limitation: telnet method = HTTP-only redirect to our
|
||||
service. Users who need end-to-end TLS must use the XML or DNS method.
|
||||
*Possible future enhancement* — a hybrid "install CA via SSH/XML, then drive
|
||||
the URL flip via Telnet" path. Feasibility unknown; not in this iteration.
|
||||
|
||||
---
|
||||
|
||||
## 7. Summary of what changes when this lands
|
||||
|
||||
- **New reusable package `pkg/telnet`** — sibling of `pkg/ssh`, line-oriented
|
||||
TCP client with `Dial`, `SendCommand`, `Probe`, `Close`, all deadline-driven.
|
||||
No external dependencies, usable from CLI, service, and tests.
|
||||
- **New `MigrationMethodTelnet = "telnet"`** constant in `pkg/service/setup/setup.go`
|
||||
plus a `migrateViaTelnet` branch in `Manager.MigrateSpeaker`.
|
||||
- **New `pkg/service/setup/telnet_migration.go`** orchestrating the URL
|
||||
configuration sequence (§2.1) on top of `pkg/telnet`. Configuration only —
|
||||
no `sys reboot` here.
|
||||
- **New `pkg/service/setup/marge_pairing.go`** with `PairAccount(deviceIP, id)`:
|
||||
probes `/supportedURLs`, time-bounded `POST /setMargeAccount`, falls back to
|
||||
telnet `envswitch accountid set <id>` on missing/wedged endpoint.
|
||||
- **`Manager.Reboot` and `HandleRebootDevice` gain a method selector** —
|
||||
signature changes to `Reboot(deviceIP string, method RebootMethod) (string, error)`
|
||||
with `RebootMethodSSH` (default, today's behavior) and `RebootMethodTelnet`
|
||||
(sends `sys reboot` over a fresh `pkg/telnet` connection). Handler reads
|
||||
`?method=ssh|telnet` from the query string.
|
||||
- **`MigrationSummary` gains** `TelnetReachable`, `TelnetBanner`,
|
||||
`TelnetCommandsAccepted`, `SetMargeAccountSupported`, `CurrentAccountID`,
|
||||
`KnownAccountIDs` so the UI can show preflight outcomes and offer reuse.
|
||||
- **UI** — `web/index.html` dropdown gets a `telnet` option (greyed out when
|
||||
preflight fails) and a new pane for picking/entering/randomizing a 7-digit
|
||||
account ID when `:8090/info` reports an empty `margeAccountUUID`. The
|
||||
existing reboot button gets a method selector (radio or dropdown) wired to
|
||||
the new query param, with `confirm()` before firing. The legacy `hosts`
|
||||
option stays out of the dropdown (deprecated).
|
||||
|
||||
---
|
||||
|
||||
## 8. Device compatibility today
|
||||
|
||||
What follows is the current best read on which devices our `migrateViaTelnet`
|
||||
flow handles end-to-end, derived from the same six sources catalogued in
|
||||
[TELNET-COMMAND-REFERENCE.md](TELNET-COMMAND-REFERENCE.md) plus the issue
|
||||
threads cited above. This is migration-outcome perspective; for per-command
|
||||
availability see the reference doc.
|
||||
|
||||
### 8.1 Proven to work end-to-end
|
||||
|
||||
All on the firmware-27.0.6 family, which is what survived through Bose's
|
||||
end-of-service cut. Multi-reporter agreement on every row.
|
||||
|
||||
| Device | Reporter(s) | Source | Confirmed |
|
||||
|----------|-----------------------------|------------------|----------------------------------------------------------------------------|
|
||||
| ST 10 | foob61451, TJGigs | #221, #228 | All four URLs persist; `envswitch boseurls set` survives `sys reboot` |
|
||||
| ST 20 | foob61451, mcdona1d, TJGigs | #221, #141, #228 | Same; multiple independent reports |
|
||||
| ST 300 | mcdona1d | #141 | `sys configuration` + `envswitch` + `sys reboot` round-trip |
|
||||
| Wave III | bveenker | #221 | URLs accepted; presets work after pairing fallback (§3) |
|
||||
| Wave IV | stephan48 | #221 | Port-17000 path **was the only one that worked** — USB-stick unlock failed |
|
||||
|
||||
The exact sequence each reporter ran by hand is the sequence our migration
|
||||
sends (§2.1). So the migration's happy path is exercised against five
|
||||
hardware variants in independent captures.
|
||||
|
||||
### 8.2 Proven to need the pairing fallback
|
||||
|
||||
Migration of the URLs themselves works on these models, but
|
||||
`POST /setMargeAccount` is missing or wedged on the firmware build, so
|
||||
pairing has to go through the telnet `envswitch accountid set <id>` path
|
||||
that `setup.PairAccount` already implements.
|
||||
|
||||
| Device | Reporter | Source | Why fallback is needed |
|
||||
|--------------------------------|----------|-----------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| ST Portable / FW 27.0.6 | jmosen | #236 | After migration: `POST /marge/streaming/support/power_on` → 502; `<margeAccountUUID/>` empty. Time-bounded HTTP path fails; envswitch fallback succeeds. |
|
||||
| BST20 Portable (factory reset) | ubittner | scheilch/opencloudtouch#167 | `<margeAccountUUID/>` empty; `/setMargeAccount` not in `/supportedURLs`. HTTP path skipped entirely; only the telnet fallback works. |
|
||||
|
||||
### 8.3 Likely to fail (but the failure is clean)
|
||||
|
||||
Our preflight + abort-on-first-rejection design (`TestMigrateViaTelnet_CommandNotFoundAborts`)
|
||||
means none of these scenarios leave a device half-configured. The user is
|
||||
told what failed and pointed to the XML or DNS method.
|
||||
|
||||
| Device | Source | Likely cause |
|
||||
|--------------------------------------------|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **SA-5** (sound amplifier) on FW 9.0.43.x | soundcork#141 | FW 9.x has a different shell generation: `->` prompt, `local_services on`, `scm uboot_ver`. **`sys configuration` and `envswitch` are not documented as working there.** Migration fails on command #1. |
|
||||
| **Recent ST Portable** (post-27.0.6.46330) | #236 (indirect) | `/setMargeAccount` removal points to broader command-set shrinkage. If `envswitch accountid set` is also gone, both migration and pairing fallback fail; user is told to pair via the official Bose app before EOS, or use XML over SSH. |
|
||||
|
||||
### 8.4 Unknown — would benefit from real-device verification
|
||||
|
||||
| Device | Why unknown | What we'd want to confirm |
|
||||
|----------------------------|---------------------------------------------------------------------|--------------------------------------------------------------------------|
|
||||
| **ST 30** (`mojo`) | No concrete capture in any of the six sources | Almost certainly works — same FW family as ST 10/20/300 — but unverified |
|
||||
| **ST 520 / Home Cinema** | USB-unlock reports failing (#141), no port-17000 capture either way | Whether `sys configuration` and `envswitch` are exposed at all |
|
||||
| **Wave Music System I/II** | `flarn2006`-era hardware, not seen in 27.x reports | Whether port 17000 is even open on those models |
|
||||
|
||||
### 8.5 The S5 "valid roots" tension
|
||||
|
||||
S5 (the r/bose telnet-probing thread) lists only `key`, `net`, `sys`,
|
||||
`getpdo` as command roots that don't return "Command not found" on its
|
||||
ST 10 / FW 27.0.6 — which would seem to rule out `envswitch`. But foob61451
|
||||
on the same hardware/firmware ran `envswitch boseurls set` successfully
|
||||
(#221).
|
||||
|
||||
The most plausible reading is that **S5 is a non-exhaustive probe**, not a
|
||||
negative claim: the author writes "I've made some educated guesses and come
|
||||
up with the following valid commands" and never says they tested
|
||||
`envswitch`. We do not down-weight `envswitch` availability on the strength
|
||||
of S5 alone — but if a real-device run ever shows `envswitch` rejected on
|
||||
an ST 10, our preflight catches it, the migration aborts on the first
|
||||
non-OK response, and the user gets a clear error rather than partial state.
|
||||
|
||||
### 8.6 Failure-mode matrix
|
||||
|
||||
What `migrateViaTelnet` does in each failure mode (verified by
|
||||
`pkg/telnet` and `pkg/service/setup` unit tests):
|
||||
|
||||
| Failure | Outcome | Test |
|
||||
|------------------------------------------------|---------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------|
|
||||
| Port 17000 closed / TCP unreachable | `Dial` errors before any command is sent; UI shows the error; nothing persisted | `TestMigrateViaTelnet_DialFailureReturnsError` |
|
||||
| `sys configuration` rejected (cmd #1) | Sequence aborts; verification not sent; rest of commands not attempted | `TestMigrateViaTelnet_CommandNotFoundAborts` (envswitch variant — generalises) |
|
||||
| `envswitch boseurls set` rejected | Sequence aborts; runtime-only `sys configuration` state reverts on reboot — no permanent damage | `TestMigrateViaTelnet_CommandNotFoundAborts` |
|
||||
| Verification mismatch (URLs not echoed back) | Loud "verification failed" error; live state may persist until reboot but UI never claims success | `TestMigrateViaTelnet_VerifyMismatchFails` |
|
||||
| `/setMargeAccount` 502 / hang | 5s connect + 12s total budget enforced; falls through to telnet `envswitch accountid set` | `TestPairAccount_FallsBackWhenHTTPReturnsServerError` |
|
||||
| `/setMargeAccount` missing in `/supportedURLs` | HTTP path skipped; goes straight to telnet `envswitch accountid set` | `TestPairAccount_FallsBackWhenSetMargeAccountMissing` |
|
||||
| Both pairing paths unavailable | Structured error: "use the official Bose app before EOS, or open SSH and use the XML method" | `TestPairAccount_NoTelnetAndHTTPMissingReturnsClearError`, `TestPairAccount_TelnetCommandNotFoundReportsBothPaths` |
|
||||
|
||||
### 8.7 TL;DR
|
||||
|
||||
- **Green light** — ST 10, ST 20, ST 300, Wave III, Wave IV on FW 27.0.6 (multi-reporter agreement).
|
||||
- **Yellow** — ST Portable and BST20 Portable: migration works, pairing needs our fallback (already implemented).
|
||||
- **Red, but fails cleanly** — SA-5 on FW 9.x, possibly newer ST Portable builds.
|
||||
- **Unverified but expected to work** — ST 30, ST 520, Wave Music System I/II.
|
||||
|
||||
The most useful next verification step is touching a real ST 30 and ST 520
|
||||
— those are the two "expected to work" models with zero concrete captures.
|
||||
Beyond that, every behaviour the doc predicts is exercised by the unit
|
||||
tests in `pkg/telnet` and `pkg/service/setup`.
|
||||
@@ -13,18 +13,18 @@ require (
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/term v0.42.0
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/term v0.43.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
golang.org/x/image v0.39.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/image v0.40.0 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
)
|
||||
|
||||
@@ -44,10 +44,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
|
||||
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
@@ -56,8 +56,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
@@ -93,8 +93,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -105,8 +105,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
@@ -117,8 +117,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
|
||||
@@ -1254,6 +1254,17 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
|
||||
}
|
||||
|
||||
sources := make([]models.ConfiguredSource, len(sourcesWrap.Sources))
|
||||
defaults := ds.getDefaultSources()
|
||||
|
||||
// Pre-claim IDs already explicitly set in the file so the canonical fill
|
||||
// below doesn't reuse them when multiple entries share a SourceKey.Type.
|
||||
claimedIDs := make(map[string]bool, len(sourcesWrap.Sources))
|
||||
for i := range sourcesWrap.Sources {
|
||||
if id := sourcesWrap.Sources[i].ID; id != "" {
|
||||
claimedIDs[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
for i := range sourcesWrap.Sources {
|
||||
ps := &sourcesWrap.Sources[i]
|
||||
s := &sources[i]
|
||||
@@ -1293,11 +1304,9 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
|
||||
s.SourceKeyAccount = s.SourceKey.Account
|
||||
}
|
||||
|
||||
// Ensure Type is populated from SourceKey if missing
|
||||
if s.Type == "" && s.SourceKey.Type != "" {
|
||||
s.Type = s.SourceKey.Type
|
||||
}
|
||||
applyCanonicalDefaults(s, defaults, claimedIDs)
|
||||
|
||||
// Last-resort ID for unknown providers.
|
||||
if s.ID == "" {
|
||||
s.ID = strconv.Itoa(2000001 + i)
|
||||
}
|
||||
@@ -1306,6 +1315,51 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
|
||||
return sources, nil
|
||||
}
|
||||
|
||||
// applyCanonicalDefaults fills missing canonical ID/Type/SourceProviderID for
|
||||
// known providers and repairs Type that was previously synthesized from
|
||||
// SourceKey.Type (e.g. "AUX") rather than the canonical value (e.g. "Audio").
|
||||
// Without this, the on-device Sources.xml — which carries only displayName +
|
||||
// sourceKey — would round-trip as id="2000001+i" type="<sourceKey.Type>" and
|
||||
// be rejected by the speaker as INVALID_SOURCE after migration.
|
||||
//
|
||||
// claimedIDs tracks which canonical IDs are already in use so that multiple
|
||||
// entries with the same SourceKey.Type don't collide on the same ID.
|
||||
func applyCanonicalDefaults(s *models.ConfiguredSource, defaults []models.ConfiguredSource, claimedIDs map[string]bool) {
|
||||
def := findCanonicalSource(defaults, s.SourceKey.Type)
|
||||
if def == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if s.ID == "" && !claimedIDs[def.ID] {
|
||||
s.ID = def.ID
|
||||
claimedIDs[def.ID] = true
|
||||
}
|
||||
|
||||
if s.Type == "" || s.Type == s.SourceKey.Type {
|
||||
s.Type = def.Type
|
||||
}
|
||||
|
||||
if s.SourceProviderID == "" {
|
||||
s.SourceProviderID = def.SourceProviderID
|
||||
}
|
||||
}
|
||||
|
||||
// findCanonicalSource returns the default source matching the given
|
||||
// SourceKey.Type, or nil if it's not one of our known providers.
|
||||
func findCanonicalSource(defaults []models.ConfiguredSource, sourceKeyType string) *models.ConfiguredSource {
|
||||
if sourceKeyType == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := range defaults {
|
||||
if defaults[i].SourceKey.Type == sourceKeyType {
|
||||
return &defaults[i]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveConfiguredSources saves the configured sources list for the specified account and device.
|
||||
func (ds *DataStore) SaveConfiguredSources(account, device string, sources []models.ConfiguredSource) error {
|
||||
ds.fileMutex.Lock()
|
||||
|
||||
@@ -98,3 +98,155 @@ func TestSaveSources_Format(t *testing.T) {
|
||||
t.Errorf("Sources.xml should not contain <sourceSettings> tag")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetConfiguredSources_MinimalAuxEntryNormalized covers the migration case from
|
||||
// issue #195: the device's on-disk Sources.xml carries only displayName + sourceKey
|
||||
// for AUX (no id, no type). When read back, the AUX entry must surface as the
|
||||
// canonical id="10001" type="Audio" sourceproviderid="9", not synthesized values.
|
||||
func TestGetConfiguredSources_MinimalAuxEntryNormalized(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-sources-min-aux-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
account := "1234567"
|
||||
device := "001122334455"
|
||||
|
||||
deviceDir := ds.AccountDeviceDir(account, device)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
minimalSourcesXML := `<sources>
|
||||
<source displayName="AUX IN" secret="">
|
||||
<sourceKey type="AUX" account="AUX" />
|
||||
</source>
|
||||
</sources>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(minimalSourcesXML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sources, err := ds.GetConfiguredSources(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfiguredSources failed: %v", err)
|
||||
}
|
||||
|
||||
if len(sources) != 1 {
|
||||
t.Fatalf("expected 1 source, got %d", len(sources))
|
||||
}
|
||||
|
||||
s := sources[0]
|
||||
if s.ID != "10001" {
|
||||
t.Errorf("expected canonical AUX id 10001, got %q", s.ID)
|
||||
}
|
||||
if s.Type != "Audio" {
|
||||
t.Errorf("expected canonical AUX type 'Audio', got %q", s.Type)
|
||||
}
|
||||
if s.SourceKey.Type != "AUX" || s.SourceKey.Account != "AUX" {
|
||||
t.Errorf("expected sourceKey type/account AUX/AUX, got %q/%q", s.SourceKey.Type, s.SourceKey.Account)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetConfiguredSources_DuplicateProviderUniqueIDs ensures that when a file
|
||||
// contains multiple entries for the same SourceKey.Type (e.g. two AUX entries),
|
||||
// only one gets the canonical ID; the rest fall back to synthesized IDs so they
|
||||
// don't collide.
|
||||
func TestGetConfiguredSources_DuplicateProviderUniqueIDs(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-sources-dup-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
account := "1234567"
|
||||
device := "001122334455"
|
||||
|
||||
deviceDir := ds.AccountDeviceDir(account, device)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dupXML := `<sources>
|
||||
<source displayName="AUX IN" secret="">
|
||||
<sourceKey type="AUX" account="AUX" />
|
||||
</source>
|
||||
<source displayName="AUX 2" secret="">
|
||||
<sourceKey type="AUX" account="AUX" />
|
||||
</source>
|
||||
</sources>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(dupXML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sources, err := ds.GetConfiguredSources(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfiguredSources failed: %v", err)
|
||||
}
|
||||
|
||||
if len(sources) != 2 {
|
||||
t.Fatalf("expected 2 sources, got %d", len(sources))
|
||||
}
|
||||
|
||||
if sources[0].ID == sources[1].ID {
|
||||
t.Errorf("duplicate AUX entries must not share an ID, got %q for both", sources[0].ID)
|
||||
}
|
||||
|
||||
// Both should still have Type repaired to the canonical "Audio".
|
||||
for i, s := range sources {
|
||||
if s.Type != "Audio" {
|
||||
t.Errorf("source %d: expected Type 'Audio', got %q", i, s.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetConfiguredSources_PoisonedAuxEntryRepaired covers the case where a previous
|
||||
// version of the datastore already persisted bad synthesized values (type="AUX",
|
||||
// id="2000001"). On read, those values must be repaired to the canonical defaults.
|
||||
func TestGetConfiguredSources_PoisonedAuxEntryRepaired(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "st-sources-poisoned-aux-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
account := "1234567"
|
||||
device := "001122334455"
|
||||
|
||||
deviceDir := ds.AccountDeviceDir(account, device)
|
||||
if err := os.MkdirAll(deviceDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
poisonedXML := `<sources>
|
||||
<source displayName="AUX IN" id="2000001" secret="" secretType="" type="AUX">
|
||||
<credential type=""></credential>
|
||||
<sourceKey type="AUX" account="AUX"></sourceKey>
|
||||
</source>
|
||||
</sources>`
|
||||
if err := os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(poisonedXML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sources, err := ds.GetConfiguredSources(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfiguredSources failed: %v", err)
|
||||
}
|
||||
|
||||
if len(sources) != 1 {
|
||||
t.Fatalf("expected 1 source, got %d", len(sources))
|
||||
}
|
||||
|
||||
s := sources[0]
|
||||
if s.Type != "Audio" {
|
||||
t.Errorf("expected Type to be repaired to 'Audio', got %q", s.Type)
|
||||
}
|
||||
// ID repair is intentionally not aggressive — only empty IDs are filled
|
||||
// from canonical defaults to avoid breaking references in recents/presets.
|
||||
if s.ID != "2000001" {
|
||||
t.Errorf("expected ID preserved as 2000001, got %q", s.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// accountIDSuggestionsResponse is the body of GET /setup/account-id-suggestions/{deviceId}.
|
||||
// `current` is the device's existing margeAccountUUID (empty when the device is fresh / factory-reset).
|
||||
// `known` is the list of accountIDs already present in the local datastore, so the UI can offer
|
||||
// the user a way to re-attach a fresh device to an existing account.
|
||||
type accountIDSuggestionsResponse struct {
|
||||
Current string `json:"current"`
|
||||
Known []string `json:"known"`
|
||||
}
|
||||
|
||||
// HandleAccountIDSuggestions returns the device's current account ID (from
|
||||
// :8090/info, empty if unset) plus the list of account IDs already present
|
||||
// in the local datastore.
|
||||
func (s *Server) HandleAccountIDSuggestions(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
writeJSONError(w, http.StatusBadRequest, "Device ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := accountIDSuggestionsResponse{}
|
||||
|
||||
if info, err := s.sm.GetLiveDeviceInfo(deviceIP); err == nil {
|
||||
resp.Current = info.MargeAccountUUID
|
||||
}
|
||||
|
||||
if known, err := s.ds.ListAccounts(); err == nil {
|
||||
resp.Known = known
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// pairAccountResponse is the body of POST /setup/pair-account/{deviceId}.
|
||||
type pairAccountResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Result setup.PairAccountResult `json:"result"`
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// HandlePairAccount associates the device with the supplied 7-digit account ID,
|
||||
// trying HTTP /setMargeAccount first and falling back to telnet
|
||||
// `envswitch accountid set`.
|
||||
//
|
||||
// Query params:
|
||||
// - account_id (required) — must pass setup.IsValidAccountID
|
||||
func (s *Server) HandlePairAccount(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
writeJSONError(w, http.StatusBadRequest, "Device ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
accountID := r.URL.Query().Get("account_id")
|
||||
if !setup.IsValidAccountID(accountID) {
|
||||
writeJSONError(w, http.StatusBadRequest, "account_id must be exactly 7 digits")
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.resolveDeviceIDToIP(deviceID)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var t setup.TelnetClient
|
||||
|
||||
if s.sm.NewTelnet != nil {
|
||||
t = s.sm.NewTelnet(deviceIP)
|
||||
if dialErr := t.Dial(); dialErr != nil {
|
||||
// Telnet not reachable — fall through with t=nil so PairAccount
|
||||
// can decide based on HTTP availability alone.
|
||||
t = nil
|
||||
} else {
|
||||
defer func() { _ = t.Close() }()
|
||||
}
|
||||
}
|
||||
|
||||
result, output, err := s.sm.PairAccount(deviceIP, accountID, t)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
body := pairAccountResponse{
|
||||
OK: err == nil,
|
||||
Result: result,
|
||||
Output: output,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
body.Error = err.Error()
|
||||
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(body); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// jsonErrorBody is the static shape of error responses from this file.
|
||||
// Avoiding map[string]interface{} keeps errchkjson satisfied: the typed
|
||||
// struct guarantees encoding can't fail with a runtime type error.
|
||||
type jsonErrorBody struct {
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// writeJSONError is a small helper for the handlers in this file to keep
|
||||
// error wiring out of the happy path. It mirrors what the rest of the
|
||||
// package does inline.
|
||||
func writeJSONError(w http.ResponseWriter, status int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(jsonErrorBody{OK: false, Message: message}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -1066,7 +1066,9 @@ func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
output, err := s.sm.Reboot(deviceIP)
|
||||
method := setup.RebootMethod(r.URL.Query().Get("method"))
|
||||
|
||||
output, err := s.sm.Reboot(deviceIP, method)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
@@ -127,6 +127,8 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
|
||||
r.Post("/revert/{deviceId}", server.HandleRevertMigration)
|
||||
r.Post("/reboot/{deviceId}", server.HandleRebootDevice)
|
||||
r.Get("/account-id-suggestions/{deviceId}", server.HandleAccountIDSuggestions)
|
||||
r.Post("/pair-account/{deviceId}", server.HandlePairAccount)
|
||||
r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert)
|
||||
r.Post("/test-connection/{deviceId}", server.HandleTestConnection)
|
||||
r.Post("/test-hosts/{deviceId}", server.HandleTestHostsRedirection)
|
||||
|
||||
@@ -727,9 +727,8 @@
|
||||
XML Configuration (Recommended - redirects
|
||||
specific services)
|
||||
</option>
|
||||
<option value="hosts">
|
||||
/etc/hosts + Root CA (Advanced - global
|
||||
redirection)
|
||||
<option value="telnet">
|
||||
Telnet (Port 17000) - no SSH required
|
||||
</option>
|
||||
<option value="resolv">
|
||||
/etc/resolv.conf (DHCP-Aware - Redirect via DNS
|
||||
@@ -758,6 +757,83 @@
|
||||
<pre id="current-resolv-content"></pre>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="telnet-method-pane"
|
||||
style="display: none; margin-bottom: 20px; padding: 10px; border: 1px solid #ddd; background-color: #f0f7ff;"
|
||||
>
|
||||
<h4 style="margin-top: 0">Telnet Migration (Port 17000)</h4>
|
||||
<p style="margin: 5px 0">
|
||||
Drives the speaker's diagnostic shell over TCP/17000.
|
||||
Requires no SSH access. Works on most ST 10/20/300 and
|
||||
Wave III/IV firmware (27.0.6.x).
|
||||
</p>
|
||||
<p style="margin: 5px 0; font-size: 0.9em; color: #555">
|
||||
Limitation: HTTP-only redirection — telnet has no way to
|
||||
install a custom CA. If you need end-to-end TLS, use the
|
||||
XML or DNS method instead.
|
||||
</p>
|
||||
<p style="margin: 5px 0; font-size: 0.9em; color: #555">
|
||||
After a successful migration a <em>Pair Account</em>
|
||||
panel will appear below this one — use it to associate
|
||||
the speaker with an account ID before presets and
|
||||
streaming work.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="pair-account-pane"
|
||||
style="display: none; margin-bottom: 20px; padding: 10px; border: 1px solid #ddd; background-color: #fff8e1;"
|
||||
>
|
||||
<h4 style="margin-top: 0">Pair Account</h4>
|
||||
<p
|
||||
id="pair-account-current"
|
||||
style="margin: 5px 0; display: none"
|
||||
></p>
|
||||
<div id="pair-account-fresh" style="display: none">
|
||||
<p style="margin: 5px 0">
|
||||
This speaker has no margeAccountUUID set
|
||||
(factory-reset or never paired). Choose an account
|
||||
ID to attach it to:
|
||||
</p>
|
||||
<div style="margin: 8px 0">
|
||||
<label for="pair-account-existing"
|
||||
>Existing account:</label
|
||||
>
|
||||
<select id="pair-account-existing">
|
||||
<option value="">-- pick from datastore --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="margin: 8px 0">
|
||||
<label for="pair-account-input">7-digit ID:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="pair-account-input"
|
||||
maxlength="7"
|
||||
pattern="[0-9]{7}"
|
||||
placeholder="1234567"
|
||||
style="font-family: monospace; width: 8em"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick="generateAccountID()"
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
id="pair-account-btn"
|
||||
type="button"
|
||||
style="margin-top: 8px"
|
||||
>
|
||||
Pair Account
|
||||
</button>
|
||||
<div
|
||||
id="pair-account-status"
|
||||
style="margin-top: 8px; font-size: 0.9em"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="original-config-pane"
|
||||
style="display: none; margin-bottom: 20px"
|
||||
|
||||
@@ -364,7 +364,7 @@ async function fetchDevices() {
|
||||
fetchSpotifyStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById("device-list").innerHTML = "Error loading devices: " + error;
|
||||
document.getElementById("device-list").textContent = "Error loading devices: " + error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,7 +484,7 @@ async function startSync() {
|
||||
status.style.backgroundColor = "#dfd";
|
||||
status.textContent = "✅ Sync completed successfully for " + display + "!";
|
||||
results.style.display = "block";
|
||||
log.innerHTML = "Data fetched and saved to local datastore for " + display + ".\nPresets: OK\nRecents: OK\nSources: OK";
|
||||
log.textContent = "Data fetched and saved to local datastore for " + display + ".\nPresets: OK\nRecents: OK\nSources: OK";
|
||||
} else {
|
||||
const err = await response.text();
|
||||
throw new Error(err);
|
||||
@@ -1709,7 +1709,7 @@ async function showSummary(deviceId) {
|
||||
statusDiv.style.backgroundColor = "#ffffcc";
|
||||
|
||||
const display = getDeviceDisplayName(deviceId);
|
||||
statusDiv.innerHTML = "Fetching summary for " + display + "...";
|
||||
statusDiv.textContent = "Fetching summary for " + display + "...";
|
||||
|
||||
const outputBox = document.getElementById("command-output-box");
|
||||
if (outputBox) outputBox.style.display = "none";
|
||||
@@ -1872,7 +1872,7 @@ async function showSummary(deviceId) {
|
||||
document.getElementById("migration-summary").scrollIntoView();
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = "#ffcccc";
|
||||
statusDiv.innerHTML = "Error fetching summary for " + display + ": " + error;
|
||||
statusDiv.textContent = "Error fetching summary for " + display + ": " + error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1910,7 +1910,7 @@ async function revert(deviceId, ip) {
|
||||
const statusDiv = document.getElementById("status");
|
||||
statusDiv.style.display = "block";
|
||||
statusDiv.style.backgroundColor = "#ffffcc";
|
||||
statusDiv.innerHTML = "Reverting " + display + " to defaults...";
|
||||
statusDiv.textContent = "Reverting " + display + " to defaults...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/revert/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
@@ -1918,14 +1918,14 @@ async function revert(deviceId, ip) {
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = "#ccffcc";
|
||||
statusDiv.innerHTML = "Successfully started revert for " + display + ".";
|
||||
statusDiv.textContent = "Successfully started revert for " + display + ".";
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = "#ffcccc";
|
||||
statusDiv.innerHTML = "Revert failed for " + display + ": " + (result.message || "Unknown error");
|
||||
statusDiv.textContent = "Revert failed for " + display + ": " + (result.message || "Unknown error");
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = "#ffcccc";
|
||||
statusDiv.innerHTML = "Error reverting " + display + ": " + error;
|
||||
statusDiv.textContent = "Error reverting " + display + ": " + error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1939,25 +1939,152 @@ async function reboot(deviceId, ip) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick the reboot transport from the migration method dropdown — telnet
|
||||
// migration likely means SSH isn't available on the device.
|
||||
const migrationMethod = (document.getElementById("migration-method") || {}).value || "";
|
||||
const rebootMethod = migrationMethod === "telnet" ? "telnet" : "ssh";
|
||||
|
||||
const statusDiv = document.getElementById("status");
|
||||
statusDiv.style.display = "block";
|
||||
statusDiv.style.backgroundColor = "#ffffcc";
|
||||
statusDiv.innerHTML = "Rebooting " + display + "...";
|
||||
// textContent avoids reinterpreting the (user-controlled) device name as HTML.
|
||||
statusDiv.textContent = "Rebooting " + display + " via " + rebootMethod + "...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/reboot/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
const url = "/setup/reboot/" + encodeURIComponent(deviceId)
|
||||
+ "?method=" + encodeURIComponent(rebootMethod);
|
||||
const response = await fetch(url, {method: "POST"},);
|
||||
const result = await response.json();
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = "#ccffcc";
|
||||
statusDiv.innerHTML = "Successfully started reboot for " + display + ".";
|
||||
statusDiv.textContent = "Successfully started reboot for " + display + ".";
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = "#ffcccc";
|
||||
statusDiv.innerHTML = "Reboot failed for " + display + ": " + (result.message || "Unknown error");
|
||||
statusDiv.textContent = "Reboot failed for " + display + ": " + (result.message || "Unknown error");
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = "#ffcccc";
|
||||
statusDiv.innerHTML = "Error rebooting " + display + ": " + error;
|
||||
statusDiv.textContent = "Error rebooting " + display + ": " + error;
|
||||
}
|
||||
}
|
||||
|
||||
// loadAccountIDSuggestions queries the server for the device's current
|
||||
// margeAccountUUID and the list of known accounts in the datastore, and
|
||||
// renders the pair-account pane accordingly.
|
||||
async function loadAccountIDSuggestions(deviceId) {
|
||||
const pane = document.getElementById("pair-account-pane");
|
||||
if (!pane) return;
|
||||
|
||||
const currentP = document.getElementById("pair-account-current");
|
||||
const freshDiv = document.getElementById("pair-account-fresh");
|
||||
const existingSelect = document.getElementById("pair-account-existing");
|
||||
const input = document.getElementById("pair-account-input");
|
||||
const btn = document.getElementById("pair-account-btn");
|
||||
const statusDiv = document.getElementById("pair-account-status");
|
||||
|
||||
pane.style.display = "block";
|
||||
statusDiv.innerText = "Loading...";
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"/setup/account-id-suggestions/" + encodeURIComponent(deviceId),
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
// Reset
|
||||
existingSelect.innerHTML = "<option value=\"\">-- pick from datastore --</option>";
|
||||
(data.known || []).forEach((/** @type {string} */ id) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(id);
|
||||
opt.textContent = String(id);
|
||||
existingSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
if (data.current) {
|
||||
currentP.style.display = "block";
|
||||
// Build the paragraph with createElement so the user-controlled
|
||||
// account ID never becomes HTML.
|
||||
currentP.replaceChildren(
|
||||
document.createTextNode("Speaker is already paired with account "),
|
||||
Object.assign(document.createElement("strong"), {textContent: data.current}),
|
||||
document.createTextNode(". You can keep it (recommended) or re-pair to a different ID."),
|
||||
);
|
||||
input.value = data.current;
|
||||
freshDiv.style.display = "block";
|
||||
} else {
|
||||
currentP.style.display = "none";
|
||||
freshDiv.style.display = "block";
|
||||
input.value = "";
|
||||
}
|
||||
|
||||
btn.onclick = () => pairAccount(deviceId);
|
||||
statusDiv.innerText = "";
|
||||
} catch (error) {
|
||||
statusDiv.innerText = "Failed to load suggestions: " + error;
|
||||
}
|
||||
}
|
||||
|
||||
// generateAccountID picks a random 7-digit ID and writes it into the input,
|
||||
// avoiding any existing IDs already shown in the dropdown so we don't
|
||||
// accidentally collide with a known datastore entry.
|
||||
function generateAccountID() {
|
||||
const select = document.getElementById("pair-account-existing");
|
||||
const input = document.getElementById("pair-account-input");
|
||||
if (!input) return;
|
||||
|
||||
const known = new Set();
|
||||
if (select) {
|
||||
Array.from(select.options).forEach((o) => {
|
||||
if (o.value) known.add(o.value);
|
||||
});
|
||||
}
|
||||
|
||||
// 7-digit number from 1_000_000 to 9_999_999.
|
||||
for (let i = 0; i < 32; i++) {
|
||||
const n = Math.floor(Math.random() * 9_000_000) + 1_000_000;
|
||||
const s = String(n);
|
||||
if (!known.has(s)) {
|
||||
input.value = s;
|
||||
return;
|
||||
}
|
||||
}
|
||||
input.value = "";
|
||||
}
|
||||
|
||||
async function pairAccount(deviceId) {
|
||||
if (!deviceId) {
|
||||
alert("Please select a device.");
|
||||
return;
|
||||
}
|
||||
const select = document.getElementById("pair-account-existing");
|
||||
const input = document.getElementById("pair-account-input");
|
||||
const statusDiv = document.getElementById("pair-account-status");
|
||||
|
||||
let accountID = (input && input.value || "").trim();
|
||||
if (!accountID && select && select.value) accountID = select.value;
|
||||
|
||||
if (!/^[0-9]{7}$/.test(accountID)) {
|
||||
statusDiv.innerText = "Account ID must be exactly 7 digits.";
|
||||
return;
|
||||
}
|
||||
|
||||
statusDiv.innerText = "Pairing...";
|
||||
|
||||
try {
|
||||
const url = "/setup/pair-account/" + encodeURIComponent(deviceId)
|
||||
+ "?account_id=" + encodeURIComponent(accountID);
|
||||
const response = await fetch(url, {method: "POST"});
|
||||
const result = await response.json();
|
||||
showCommandOutput({ok: result.ok, output: result.output, message: result.error});
|
||||
if (result.ok) {
|
||||
statusDiv.innerText = "Paired via " + (result.result && result.result.method || "?")
|
||||
+ ". Reboot the speaker to apply.";
|
||||
} else {
|
||||
statusDiv.innerText = "Pair failed: " + (result.error || "unknown error");
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.innerText = "Error pairing: " + error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1983,7 +2110,7 @@ async function migrate(deviceId, ip) {
|
||||
statusDiv.style.display = "block";
|
||||
statusDiv.style.backgroundColor = "#ffffcc";
|
||||
const display = getDeviceDisplayName(deviceId);
|
||||
statusDiv.innerHTML = "Migrating " + display + " using " + method + "...";
|
||||
statusDiv.textContent = "Migrating " + display + " using " + method + "...";
|
||||
|
||||
let query = "?method=" + encodeURIComponent(method) + "&target_url=" + encodeURIComponent(targetUrl);
|
||||
for (let k in opts) {
|
||||
@@ -1996,7 +2123,14 @@ async function migrate(deviceId, ip) {
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = "#ccffcc";
|
||||
statusDiv.innerHTML = "Successfully started migration for " + display + ". <strong>Please reboot the device to activate the changes.</strong>";
|
||||
// replaceChildren keeps the user-controlled `display` outside any
|
||||
// HTML-parsing context, while preserving the intentional <strong>.
|
||||
statusDiv.replaceChildren(
|
||||
document.createTextNode("Successfully started migration for " + display + ". "),
|
||||
Object.assign(document.createElement("strong"), {
|
||||
textContent: "Please reboot the device to activate the changes.",
|
||||
}),
|
||||
);
|
||||
|
||||
// Make reboot button available and prominent
|
||||
const rebootBtn = document.getElementById("reboot-speaker-btn");
|
||||
@@ -2004,15 +2138,22 @@ async function migrate(deviceId, ip) {
|
||||
rebootBtn.disabled = false;
|
||||
rebootBtn.style.border = "2px solid #000";
|
||||
|
||||
// For the telnet path, surface the account-id picker. Pairing is
|
||||
// only needed when the device's margeAccountUUID is empty, but
|
||||
// we always show the panel so the user can re-pair if they want.
|
||||
if (method === "telnet") {
|
||||
loadAccountIDSuggestions(deviceId);
|
||||
}
|
||||
|
||||
// Re-show summary but with prominence on reboot
|
||||
summaryDiv.style.display = "block";
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = "#ffcccc";
|
||||
statusDiv.innerHTML = "Migration failed for " + display + ": " + (result.message || "Unknown error");
|
||||
statusDiv.textContent = "Migration failed for " + display + ": " + (result.message || "Unknown error");
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = "#ffcccc";
|
||||
statusDiv.innerHTML = "Error migrating " + display + ": " + error;
|
||||
statusDiv.textContent = "Error migrating " + display + ": " + error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2025,7 +2166,7 @@ async function trustCA(deviceId, ip) {
|
||||
statusDiv.style.display = "block";
|
||||
statusDiv.style.backgroundColor = "#ffffcc";
|
||||
const display = getDeviceDisplayName(deviceId);
|
||||
statusDiv.innerHTML = "Injecting Root CA into shared trust store on " + display + "...";
|
||||
statusDiv.textContent = "Injecting Root CA into shared trust store on " + display + "...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/trust-ca/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
@@ -2033,15 +2174,15 @@ async function trustCA(deviceId, ip) {
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = "#ccffcc";
|
||||
statusDiv.innerHTML = "Successfully injected Root CA on " + display + ".";
|
||||
statusDiv.textContent = "Successfully injected Root CA on " + display + ".";
|
||||
showSummary(deviceId); // Refresh to update status
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = "#ffcccc";
|
||||
statusDiv.innerHTML = "Failed to trust CA on " + display + ": " + (result.message || "Unknown error");
|
||||
statusDiv.textContent = "Failed to trust CA on " + display + ": " + (result.message || "Unknown error");
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = "#ffcccc";
|
||||
statusDiv.innerHTML = "Error trusting CA on " + display + ": " + error;
|
||||
statusDiv.textContent = "Error trusting CA on " + display + ": " + error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2057,7 +2198,7 @@ async function ensureRemoteServices(deviceId, ip) {
|
||||
statusDiv.style.display = "block";
|
||||
statusDiv.style.backgroundColor = "#ffffcc";
|
||||
const display = getDeviceDisplayName(deviceId);
|
||||
statusDiv.innerHTML = "Ensuring remote services for " + display + "...";
|
||||
statusDiv.textContent = "Ensuring remote services for " + display + "...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/ensure-remote-services/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
@@ -2065,14 +2206,14 @@ async function ensureRemoteServices(deviceId, ip) {
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = "#ccffcc";
|
||||
statusDiv.innerHTML = "Successfully ensured remote services for " + display + ".";
|
||||
statusDiv.textContent = "Successfully ensured remote services for " + display + ".";
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = "#ffcccc";
|
||||
statusDiv.innerHTML = "Failed to ensure remote services for " + display + ": " + (result.message || "Unknown error");
|
||||
statusDiv.textContent = "Failed to ensure remote services for " + display + ": " + (result.message || "Unknown error");
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = "#ffcccc";
|
||||
statusDiv.innerHTML = "Error ensuring remote services for " + display + ": " + error;
|
||||
statusDiv.textContent = "Error ensuring remote services for " + display + ": " + error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2091,7 +2232,7 @@ async function removeRemoteServices(deviceId, ip) {
|
||||
const statusDiv = document.getElementById("status");
|
||||
statusDiv.style.display = "block";
|
||||
statusDiv.style.backgroundColor = "#ffffcc";
|
||||
statusDiv.innerHTML = "Removing remote services for " + display + "...";
|
||||
statusDiv.textContent = "Removing remote services for " + display + "...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/remove-remote-services/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
@@ -2099,14 +2240,14 @@ async function removeRemoteServices(deviceId, ip) {
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = "#ccffcc";
|
||||
statusDiv.innerHTML = "Successfully removed remote services from " + display + ".";
|
||||
statusDiv.textContent = "Successfully removed remote services from " + display + ".";
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = "#ffcccc";
|
||||
statusDiv.innerHTML = "Failed to remove remote services for " + display + ": " + (result.message || "Unknown error");
|
||||
statusDiv.textContent = "Failed to remove remote services for " + display + ": " + (result.message || "Unknown error");
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = "#ffcccc";
|
||||
statusDiv.innerHTML = "Error removing remote services for " + display + ": " + error;
|
||||
statusDiv.textContent = "Error removing remote services for " + display + ": " + error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2119,7 +2260,7 @@ async function backupConfig(deviceId, ip) {
|
||||
statusDiv.style.display = "block";
|
||||
statusDiv.style.backgroundColor = "#ffffcc";
|
||||
const display = getDeviceDisplayName(deviceId);
|
||||
statusDiv.innerHTML = "Creating backup for " + display + "...";
|
||||
statusDiv.textContent = "Creating backup for " + display + "...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/setup/backup/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
@@ -2127,15 +2268,15 @@ async function backupConfig(deviceId, ip) {
|
||||
showCommandOutput(result);
|
||||
if (result.ok) {
|
||||
statusDiv.style.backgroundColor = "#ccffcc";
|
||||
statusDiv.innerHTML = "Successfully created backup for " + display + ".";
|
||||
statusDiv.textContent = "Successfully created backup for " + display + ".";
|
||||
showSummary(deviceId); // Refresh
|
||||
} else {
|
||||
statusDiv.style.backgroundColor = "#ffcccc";
|
||||
statusDiv.innerHTML = "Backup failed for " + display + ": " + (result.message || "Unknown error");
|
||||
statusDiv.textContent = "Backup failed for " + display + ": " + (result.message || "Unknown error");
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.style.backgroundColor = "#ffcccc";
|
||||
statusDiv.innerHTML = "Error creating backup for " + display + ": " + error;
|
||||
statusDiv.textContent = "Error creating backup for " + display + ": " + error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2238,10 +2379,23 @@ async function toggleMigrationMethod() {
|
||||
const serviceOptions = document.getElementById("service-options");
|
||||
const hostsTestPane = document.getElementById("hosts-redirection-test");
|
||||
const dnsTestPane = document.getElementById("dns-redirection-test");
|
||||
const telnetPane = document.getElementById("telnet-method-pane");
|
||||
|
||||
const dnsWarning = document.getElementById("dns-port-warning");
|
||||
|
||||
if (method === "hosts") {
|
||||
if (telnetPane) telnetPane.style.display = method === "telnet" ? "block" : "none";
|
||||
|
||||
if (method === "telnet") {
|
||||
xmlDiffPane.style.display = "none";
|
||||
plannedXmlPane.style.display = "none";
|
||||
plannedHostsPane.style.display = "none";
|
||||
plannedResolvPane.style.display = "none";
|
||||
currentResolvPane.style.display = "none";
|
||||
serviceOptions.style.display = "none";
|
||||
hostsTestPane.style.display = "none";
|
||||
dnsTestPane.style.display = "none";
|
||||
if (dnsWarning) dnsWarning.style.display = "none";
|
||||
} else if (method === "hosts") {
|
||||
xmlDiffPane.style.display = "none";
|
||||
plannedXmlPane.style.display = "none";
|
||||
plannedHostsPane.style.display = "block";
|
||||
|
||||
@@ -105,7 +105,10 @@ 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) {
|
||||
// AUX must be normalized to Type="Audio" — the speaker rejects type="AUX"
|
||||
// (which the datastore previously synthesized from SourceKey.Type).
|
||||
// Bluetooth is left alone since its canonical Type isn't "Audio".
|
||||
if s.Type == "" || (s.SourceKey.Type != "" && s.SourceKey.Type != constants.ProviderBluetooth) {
|
||||
if s.SourceKey.Type == constants.ProviderAmazon {
|
||||
s.Type = constants.ProviderAmazon
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PairAccountTimeouts bounds every step of the pairing call so a wedged
|
||||
// device cannot stall the migration UI indefinitely.
|
||||
const (
|
||||
supportedURLsTimeout = 3 * time.Second
|
||||
setMargeAccountConn = 5 * time.Second
|
||||
setMargeAccountTotal = 12 * time.Second
|
||||
)
|
||||
|
||||
// PairAccountResult records what was attempted, so the UI can show a
|
||||
// breadcrumb of which path actually succeeded (or that both failed).
|
||||
type PairAccountResult struct {
|
||||
SetMargeAccountSupported bool `json:"set_marge_account_supported"`
|
||||
HTTPAttempted bool `json:"http_attempted"`
|
||||
HTTPError string `json:"http_error,omitempty"`
|
||||
TelnetAttempted bool `json:"telnet_attempted"`
|
||||
TelnetError string `json:"telnet_error,omitempty"`
|
||||
Method string `json:"method"` // "http" | "telnet" | ""
|
||||
}
|
||||
|
||||
// PairAccount associates the speaker at deviceIP with accountID. It tries
|
||||
// the device's HTTP /setMargeAccount endpoint first; on missing endpoint or
|
||||
// any time-bounded failure it falls back to a telnet
|
||||
// `envswitch accountid set <id>` over the supplied client. If telnet is nil
|
||||
// or also fails, PairAccount returns a structured error explaining the next
|
||||
// step a user can take.
|
||||
func (m *Manager) PairAccount(deviceIP, accountID string, t TelnetClient) (PairAccountResult, string, error) {
|
||||
var (
|
||||
result PairAccountResult
|
||||
logs strings.Builder
|
||||
)
|
||||
|
||||
if !IsValidAccountID(accountID) {
|
||||
return result, "", fmt.Errorf("invalid account ID %q: must be exactly 7 digits", accountID)
|
||||
}
|
||||
|
||||
supported, supportedErr := m.probeSetMargeAccount(deviceIP)
|
||||
result.SetMargeAccountSupported = supported
|
||||
|
||||
switch {
|
||||
case supportedErr != nil:
|
||||
fmt.Fprintf(&logs, "supportedURLs probe failed: %v\n", supportedErr)
|
||||
case supported:
|
||||
logs.WriteString("supportedURLs lists /setMargeAccount — trying HTTP\n")
|
||||
default:
|
||||
logs.WriteString("supportedURLs does NOT list /setMargeAccount — skipping HTTP, going straight to telnet\n")
|
||||
}
|
||||
|
||||
if supported {
|
||||
result.HTTPAttempted = true
|
||||
|
||||
if err := m.postSetMargeAccount(deviceIP, accountID); err != nil {
|
||||
result.HTTPError = err.Error()
|
||||
|
||||
fmt.Fprintf(&logs, "HTTP /setMargeAccount failed: %v\n", err)
|
||||
} else {
|
||||
result.Method = "http"
|
||||
|
||||
logs.WriteString("HTTP /setMargeAccount succeeded\n")
|
||||
|
||||
return result, logs.String(), nil
|
||||
}
|
||||
}
|
||||
|
||||
if t == nil {
|
||||
return result, logs.String(), errors.New(
|
||||
"pairing failed: HTTP /setMargeAccount unavailable and no telnet client supplied — " +
|
||||
"open the official Bose app and pair manually before EOS, or use the SSH-based XML method")
|
||||
}
|
||||
|
||||
result.TelnetAttempted = true
|
||||
|
||||
cmd := "envswitch accountid set " + accountID
|
||||
|
||||
resp, err := t.SendCommand(cmd)
|
||||
if err != nil {
|
||||
result.TelnetError = err.Error()
|
||||
|
||||
return result, logs.String(), fmt.Errorf("HTTP unavailable and telnet fallback failed: %w", err)
|
||||
}
|
||||
|
||||
if isCommandNotFound(resp) {
|
||||
result.TelnetError = "envswitch accountid: command not found on this firmware"
|
||||
|
||||
return result, logs.String(), errors.New(
|
||||
"pairing failed: HTTP /setMargeAccount missing AND telnet `envswitch accountid` rejected — " +
|
||||
"firmware does not expose either pairing path")
|
||||
}
|
||||
|
||||
fmt.Fprintf(&logs, "Telnet %q → %s\n", cmd, strings.TrimRight(resp, "\r\n"))
|
||||
|
||||
result.Method = "telnet"
|
||||
|
||||
return result, logs.String(), nil
|
||||
}
|
||||
|
||||
// probeSetMargeAccount fetches /supportedURLs and reports whether
|
||||
// /setMargeAccount is in the listing.
|
||||
func (m *Manager) probeSetMargeAccount(deviceIP string) (bool, error) {
|
||||
url := buildDeviceURL(deviceIP, "/supportedURLs")
|
||||
|
||||
client := &http.Client{Timeout: supportedURLsTimeout}
|
||||
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("GET %s: %w", url, err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Errorf("GET %s returned %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read %s: %w", url, err)
|
||||
}
|
||||
|
||||
var doc struct {
|
||||
URLs []struct {
|
||||
Location string `xml:"location,attr"`
|
||||
} `xml:"URL"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(body, &doc); err != nil {
|
||||
// Fallback to substring match — some firmwares return a slightly
|
||||
// different XML root that Go's strict parser refuses.
|
||||
return strings.Contains(string(body), "/setMargeAccount"), nil
|
||||
}
|
||||
|
||||
for _, u := range doc.URLs {
|
||||
if u.Location == "/setMargeAccount" {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// postSetMargeAccount sends the pairing XML body to the device's
|
||||
// /setMargeAccount endpoint with bounded timeouts.
|
||||
func (m *Manager) postSetMargeAccount(deviceIP, accountID string) error {
|
||||
url := buildDeviceURL(deviceIP, "/setMargeAccount")
|
||||
|
||||
body := fmt.Sprintf(
|
||||
`<PairDeviceWithAccount><accountId>%s</accountId><userAuthToken>aftertouch</userAuthToken></PairDeviceWithAccount>`,
|
||||
accountID,
|
||||
)
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: setMargeAccountTotal,
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: setMargeAccountConn}).DialContext,
|
||||
ResponseHeaderTimeout: setMargeAccountTotal - setMargeAccountConn,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Post(url, "application/xml", strings.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("POST %s: %w", url, err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
return fmt.Errorf("POST %s returned %d: %s", url, resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildDeviceURL builds a URL for a SoundTouch device's HTTP API. If
|
||||
// deviceIP already includes a port (test scenarios using httptest) it is
|
||||
// reused as-is; otherwise the canonical port 8090 is appended.
|
||||
func buildDeviceURL(deviceIP, path string) string {
|
||||
if _, _, err := net.SplitHostPort(deviceIP); err == nil {
|
||||
return "http://" + deviceIP + path
|
||||
}
|
||||
|
||||
return "http://" + deviceIP + ":8090" + path
|
||||
}
|
||||
|
||||
// IsValidAccountID reports whether s is a syntactically valid SoundTouch
|
||||
// account ID — exactly 7 numeric digits, the format used by every
|
||||
// Bose-cloud-issued ID we have observed in captures.
|
||||
func IsValidAccountID(s string) bool {
|
||||
if len(s) != 7 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, ch := range s {
|
||||
if ch < '0' || ch > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// GenerateAccountID returns a fresh 7-digit account ID that does not collide
|
||||
// with any value in known. It uses crypto/rand and re-rolls on collision.
|
||||
func GenerateAccountID(known []string) (string, error) {
|
||||
taken := make(map[string]bool, len(known))
|
||||
for _, k := range known {
|
||||
taken[k] = true
|
||||
}
|
||||
|
||||
const maxAttempts = 32
|
||||
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
// 7-digit space starts at 1_000_000 to avoid leading zeros, ending at
|
||||
// 9_999_999. Range size is 9_000_000.
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(9_000_000))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("crypto/rand: %w", err)
|
||||
}
|
||||
|
||||
candidate := fmt.Sprintf("%07d", n.Int64()+1_000_000)
|
||||
if !taken[candidate] {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("could not generate a non-colliding account ID after 32 attempts")
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeDevice spins up an httptest.Server that pretends to be the SoundTouch
|
||||
// device's :8090 HTTP API. It records POSTs to /setMargeAccount so tests
|
||||
// can assert on the body.
|
||||
type fakeDevice struct {
|
||||
srv *httptest.Server
|
||||
addr string // "host:port" usable as deviceIP
|
||||
supportsSetMarge bool
|
||||
postStatus int // status code returned for POST /setMargeAccount
|
||||
postDelay time.Duration
|
||||
gotPostBody string
|
||||
}
|
||||
|
||||
func newFakeDevice(t *testing.T) *fakeDevice {
|
||||
t.Helper()
|
||||
|
||||
d := &fakeDevice{
|
||||
supportsSetMarge: true,
|
||||
postStatus: http.StatusOK,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/supportedURLs", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
|
||||
if d.supportsSetMarge {
|
||||
_, _ = w.Write([]byte(`<supportedURLs><URL location="/setMargeAccount"/><URL location="/info"/></supportedURLs>`))
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write([]byte(`<supportedURLs><URL location="/info"/></supportedURLs>`))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/setMargeAccount", func(w http.ResponseWriter, r *http.Request) {
|
||||
if d.postDelay > 0 {
|
||||
time.Sleep(d.postDelay)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
d.gotPostBody = string(body)
|
||||
|
||||
w.WriteHeader(d.postStatus)
|
||||
})
|
||||
|
||||
d.srv = httptest.NewServer(mux)
|
||||
|
||||
u := d.srv.URL[len("http://"):]
|
||||
|
||||
host, port, err := net.SplitHostPort(u)
|
||||
if err != nil {
|
||||
t.Fatalf("split httptest URL: %v", err)
|
||||
}
|
||||
|
||||
d.addr = host + ":" + port
|
||||
|
||||
t.Cleanup(d.srv.Close)
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
func TestPairAccount_HappyPathHTTP(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
res, _, err := m.PairAccount(d.addr, "1234567", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PairAccount: %v", err)
|
||||
}
|
||||
|
||||
if res.Method != "http" {
|
||||
t.Errorf("Method = %q, want http", res.Method)
|
||||
}
|
||||
|
||||
if !res.SetMargeAccountSupported {
|
||||
t.Error("SetMargeAccountSupported should be true")
|
||||
}
|
||||
|
||||
if !res.HTTPAttempted {
|
||||
t.Error("HTTPAttempted should be true")
|
||||
}
|
||||
|
||||
if res.TelnetAttempted {
|
||||
t.Error("TelnetAttempted should be false on the happy HTTP path")
|
||||
}
|
||||
|
||||
if !strings.Contains(d.gotPostBody, "<accountId>1234567</accountId>") {
|
||||
t.Errorf("device received %q, want <accountId>1234567</accountId>", d.gotPostBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairAccount_FallsBackWhenSetMargeAccountMissing(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.supportsSetMarge = false
|
||||
|
||||
f := &fakeTelnet{
|
||||
responses: map[string]string{"envswitch accountid set 1234567": "OK\n"},
|
||||
}
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
res, _, err := m.PairAccount(d.addr, "1234567", f)
|
||||
if err != nil {
|
||||
t.Fatalf("PairAccount: %v", err)
|
||||
}
|
||||
|
||||
if res.Method != "telnet" {
|
||||
t.Errorf("Method = %q, want telnet", res.Method)
|
||||
}
|
||||
|
||||
if res.SetMargeAccountSupported {
|
||||
t.Error("SetMargeAccountSupported should be false")
|
||||
}
|
||||
|
||||
if res.HTTPAttempted {
|
||||
t.Error("HTTPAttempted should be false when supportedURLs reports the endpoint missing")
|
||||
}
|
||||
|
||||
if !res.TelnetAttempted {
|
||||
t.Error("TelnetAttempted should be true")
|
||||
}
|
||||
|
||||
if len(f.commands) != 1 || f.commands[0] != "envswitch accountid set 1234567" {
|
||||
t.Errorf("telnet commands = %v, want one envswitch accountid", f.commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairAccount_FallsBackWhenHTTPReturnsServerError(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.postStatus = http.StatusBadGateway
|
||||
|
||||
f := &fakeTelnet{
|
||||
responses: map[string]string{"envswitch accountid set 7654321": "OK\n"},
|
||||
}
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
res, _, err := m.PairAccount(d.addr, "7654321", f)
|
||||
if err != nil {
|
||||
t.Fatalf("PairAccount: %v", err)
|
||||
}
|
||||
|
||||
if res.Method != "telnet" {
|
||||
t.Errorf("Method = %q, want telnet", res.Method)
|
||||
}
|
||||
|
||||
if res.HTTPError == "" {
|
||||
t.Error("HTTPError should be populated when POST returned 502")
|
||||
}
|
||||
|
||||
if !res.TelnetAttempted {
|
||||
t.Error("TelnetAttempted should be true after HTTP failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairAccount_HTTPSuccessSkipsTelnet(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
|
||||
f := &fakeTelnet{}
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
res, _, err := m.PairAccount(d.addr, "1234567", f)
|
||||
if err != nil {
|
||||
t.Fatalf("PairAccount: %v", err)
|
||||
}
|
||||
|
||||
if res.Method != "http" {
|
||||
t.Errorf("Method = %q, want http", res.Method)
|
||||
}
|
||||
|
||||
if len(f.commands) != 0 {
|
||||
t.Errorf("telnet should not have been used; commands = %v", f.commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairAccount_NoTelnetAndHTTPMissingReturnsClearError(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.supportsSetMarge = false
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
_, _, err := m.PairAccount(d.addr, "1234567", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when both paths are unavailable")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "no telnet client") {
|
||||
t.Errorf("err = %v, want to mention missing telnet client", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairAccount_TelnetCommandNotFoundReportsBothPaths(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.supportsSetMarge = false
|
||||
|
||||
f := &fakeTelnet{
|
||||
responses: map[string]string{"envswitch accountid set 1234567": "Command not found\n"},
|
||||
}
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
_, _, err := m.PairAccount(d.addr, "1234567", f)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when telnet rejects the fallback")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "envswitch") {
|
||||
t.Errorf("err = %v, want to mention envswitch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairAccount_RejectsInvalidAccountID(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
for _, badID := range []string{"", "12345", "12345678", "abcdefg", "12345 6"} {
|
||||
_, _, err := m.PairAccount("127.0.0.1:9999", badID, nil)
|
||||
if err == nil {
|
||||
t.Errorf("PairAccount accepted invalid ID %q", badID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairAccount_TelnetTransportErrorReturned(t *testing.T) {
|
||||
d := newFakeDevice(t)
|
||||
d.supportsSetMarge = false
|
||||
|
||||
f := &fakeTelnet{
|
||||
fail: map[string]error{"envswitch accountid set 1234567": errors.New("connection reset")},
|
||||
}
|
||||
|
||||
m := &Manager{}
|
||||
|
||||
_, _, err := m.PairAccount(d.addr, "1234567", f)
|
||||
if err == nil {
|
||||
t.Fatal("expected telnet transport error to be surfaced")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "connection reset") {
|
||||
t.Errorf("err = %v, want to wrap connection reset", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidAccountID(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"1234567", true},
|
||||
{"0000000", true},
|
||||
{"9999999", true},
|
||||
{"", false},
|
||||
{"123456", false},
|
||||
{"12345678", false},
|
||||
{"123456a", false},
|
||||
{"-123456", false},
|
||||
{" 123456", false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := IsValidAccountID(tc.in); got != tc.want {
|
||||
t.Errorf("IsValidAccountID(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAccountID_AvoidsCollisions(t *testing.T) {
|
||||
id, err := GenerateAccountID(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccountID(nil): %v", err)
|
||||
}
|
||||
|
||||
if !IsValidAccountID(id) {
|
||||
t.Errorf("generated ID %q is not valid", id)
|
||||
}
|
||||
|
||||
// Block out a fairly small space and check we still get a fresh ID.
|
||||
known := []string{"1000000", "1000001", "1000002"}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
got, err := GenerateAccountID(known)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccountID: %v", err)
|
||||
}
|
||||
|
||||
for _, k := range known {
|
||||
if got == k {
|
||||
t.Errorf("generated %q collides with known list %v", got, known)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReboot_DefaultIsSSH(t *testing.T) {
|
||||
var ranCmds []string
|
||||
|
||||
m := &Manager{
|
||||
NewSSH: func(host string) SSHClient {
|
||||
return &mockSSH{runFunc: func(cmd string) (string, error) {
|
||||
ranCmds = append(ranCmds, cmd)
|
||||
return "ok\n", nil
|
||||
}}
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := m.Reboot("192.0.2.1", ""); err != nil {
|
||||
t.Fatalf("Reboot: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, c := range ranCmds {
|
||||
if strings.Contains(c, "reboot") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("expected SSH `reboot` command, got %v", ranCmds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReboot_TelnetSendsSysReboot(t *testing.T) {
|
||||
f := &fakeTelnet{
|
||||
responses: map[string]string{"sys reboot": "OK\n"},
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
NewTelnet: func(host string) TelnetClient { return f },
|
||||
}
|
||||
|
||||
if _, err := m.Reboot("192.0.2.1", RebootMethodTelnet); err != nil {
|
||||
t.Fatalf("Reboot: %v", err)
|
||||
}
|
||||
|
||||
if len(f.commands) != 1 || f.commands[0] != "sys reboot" {
|
||||
t.Errorf("commands = %v, want [sys reboot]", f.commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReboot_TelnetTreatsCloseAsSuccess(t *testing.T) {
|
||||
// The device closes the socket as part of rebooting. SendCommand surfaces
|
||||
// that as an EOF/closed error; the reboot path must absorb it.
|
||||
f := &fakeTelnet{
|
||||
fail: map[string]error{"sys reboot": errors.New("EOF")},
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
NewTelnet: func(host string) TelnetClient { return f },
|
||||
}
|
||||
|
||||
out, err := m.Reboot("192.0.2.1", RebootMethodTelnet)
|
||||
if err != nil {
|
||||
t.Fatalf("Reboot should swallow socket-close after sys reboot, got %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(out, "connection closed by reboot") {
|
||||
t.Errorf("output should annotate the close, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReboot_TelnetSurfacesDialError(t *testing.T) {
|
||||
f := &fakeTelnet{dialErr: errors.New("connection refused")}
|
||||
m := &Manager{
|
||||
NewTelnet: func(host string) TelnetClient { return f },
|
||||
}
|
||||
|
||||
if _, err := m.Reboot("192.0.2.1", RebootMethodTelnet); err == nil {
|
||||
t.Fatal("expected dial error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReboot_UnknownMethodErrors(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
if _, err := m.Reboot("192.0.2.1", RebootMethod("ftp")); err == nil {
|
||||
t.Fatal("expected error for unsupported reboot method")
|
||||
}
|
||||
}
|
||||
+116
-3
@@ -3,6 +3,7 @@ package setup
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/ssh"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/telnet"
|
||||
)
|
||||
|
||||
// MigrationMethod represents the method used to migrate a speaker.
|
||||
@@ -32,6 +34,9 @@ const (
|
||||
MigrationMethodHosts MigrationMethod = "hosts"
|
||||
// MigrationMethodResolvConf redirects services by injecting a priority DNS hook into the DHCP logic and updating the CA trust store.
|
||||
MigrationMethodResolvConf MigrationMethod = "resolv"
|
||||
// MigrationMethodTelnet redirects services by driving the device's diagnostic
|
||||
// shell on TCP port 17000. Requires no SSH access on the device.
|
||||
MigrationMethodTelnet MigrationMethod = "telnet"
|
||||
)
|
||||
|
||||
// SoundTouchSdkPrivateCfgPath is the path to the speaker's private configuration file on device.
|
||||
@@ -77,6 +82,17 @@ type MigrationSummary struct {
|
||||
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
|
||||
SkipMirrorEndpoints []string `json:"skip_mirror_endpoints,omitempty"`
|
||||
PreferredSource string `json:"preferred_source,omitempty"`
|
||||
|
||||
// Telnet (port 17000) preflight state — populated when the user is about to
|
||||
// or has just used MigrationMethodTelnet.
|
||||
TelnetReachable bool `json:"telnet_reachable"`
|
||||
TelnetBanner string `json:"telnet_banner,omitempty"`
|
||||
TelnetVerifiedConfig string `json:"telnet_verified_config,omitempty"`
|
||||
TelnetProbeError string `json:"telnet_probe_error,omitempty"`
|
||||
|
||||
// KnownAccountIDs are accountIDs already present in the local datastore;
|
||||
// the UI offers them as choices when pairing a fresh device.
|
||||
KnownAccountIDs []string `json:"known_account_ids,omitempty"`
|
||||
}
|
||||
|
||||
// SSHClient defines the interface for SSH operations.
|
||||
@@ -85,12 +101,23 @@ type SSHClient interface {
|
||||
UploadContent(content []byte, remotePath string) error
|
||||
}
|
||||
|
||||
// TelnetClient defines the interface for the device's port-17000 diagnostic
|
||||
// shell. The concrete implementation lives in github.com/gesellix/bose-soundtouch/pkg/telnet;
|
||||
// the interface exists so tests can substitute a mock.
|
||||
type TelnetClient interface {
|
||||
Dial() error
|
||||
Probe() (string, error)
|
||||
SendCommand(cmd string) (string, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Manager handles the migration of speakers to the service.
|
||||
type Manager struct {
|
||||
ServerURL string
|
||||
DataStore *datastore.DataStore
|
||||
Crypto *certmanager.CertificateManager
|
||||
NewSSH func(host string) SSHClient
|
||||
NewTelnet func(host string) TelnetClient
|
||||
|
||||
// GetDNSRunning is an optional callback to check the actual state of the DNS server.
|
||||
GetDNSRunning func() (bool, string)
|
||||
@@ -112,6 +139,9 @@ func NewManager(serverURL string, ds *datastore.DataStore, cm *certmanager.Certi
|
||||
NewSSH: func(host string) SSHClient {
|
||||
return ssh.NewClient(host)
|
||||
},
|
||||
NewTelnet: func(host string) TelnetClient {
|
||||
return telnet.NewClient(host)
|
||||
},
|
||||
HTTPGet: http.Get,
|
||||
MgmtUsername: "admin",
|
||||
MgmtPassword: "change_me!",
|
||||
@@ -653,6 +683,13 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
|
||||
method = MigrationMethodXML
|
||||
}
|
||||
|
||||
// Telnet is SSH-free by design — skip the SSH-based off-device backup and
|
||||
// rw pre-flight, both of which would fail on devices that haven't been
|
||||
// rooted via remote_services.
|
||||
if method == MigrationMethodTelnet {
|
||||
return m.migrateViaTelnet(deviceIP, targetURL)
|
||||
}
|
||||
|
||||
var logs string
|
||||
|
||||
// 0. Off-device backup for safety
|
||||
@@ -1778,12 +1815,40 @@ func (m *Manager) RemoveRemoteServices(deviceIP string) (string, error) {
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
// Reboot reboots the speaker at the given IP.
|
||||
func (m *Manager) Reboot(deviceIP string) (string, error) {
|
||||
// RebootMethod selects the transport used to reboot a speaker.
|
||||
type RebootMethod string
|
||||
|
||||
const (
|
||||
// RebootMethodSSH reboots via SSH `reboot` (the original behavior). Requires
|
||||
// a rooted device (remote_services unlocked).
|
||||
RebootMethodSSH RebootMethod = "ssh"
|
||||
// RebootMethodTelnet reboots via the device's port-17000 diagnostic shell
|
||||
// using `sys reboot`. Requires no SSH access.
|
||||
RebootMethodTelnet RebootMethod = "telnet"
|
||||
)
|
||||
|
||||
// Reboot reboots the speaker at the given IP using the requested transport.
|
||||
// An empty method defaults to RebootMethodSSH, preserving prior behavior.
|
||||
func (m *Manager) Reboot(deviceIP string, method RebootMethod) (string, error) {
|
||||
if method == "" {
|
||||
method = RebootMethodSSH
|
||||
}
|
||||
|
||||
switch method {
|
||||
case RebootMethodSSH:
|
||||
return m.rebootViaSSH(deviceIP)
|
||||
case RebootMethodTelnet:
|
||||
return m.rebootViaTelnet(deviceIP)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported reboot method: %s", method)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) rebootViaSSH(deviceIP string) (string, error) {
|
||||
client := m.NewSSH(deviceIP)
|
||||
rwCmd := "(rw || mount -o remount,rw /)"
|
||||
|
||||
fmt.Printf("Rebooting speaker at %s\n", deviceIP)
|
||||
fmt.Printf("Rebooting speaker at %s via SSH\n", deviceIP)
|
||||
|
||||
out, err := client.Run(fmt.Sprintf("%s && reboot", rwCmd))
|
||||
if err != nil {
|
||||
@@ -1793,6 +1858,54 @@ func (m *Manager) Reboot(deviceIP string) (string, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Manager) rebootViaTelnet(deviceIP string) (string, error) {
|
||||
if m.NewTelnet == nil {
|
||||
return "", errors.New("telnet reboot not configured: Manager.NewTelnet is nil")
|
||||
}
|
||||
|
||||
fmt.Printf("Rebooting speaker at %s via telnet\n", deviceIP)
|
||||
|
||||
t := m.NewTelnet(deviceIP)
|
||||
if err := t.Dial(); err != nil {
|
||||
return "", fmt.Errorf("telnet dial %s:17000 failed: %w", deviceIP, err)
|
||||
}
|
||||
|
||||
defer func() { _ = t.Close() }()
|
||||
|
||||
// We deliberately don't wait for a response — the device closes the socket
|
||||
// as part of rebooting, and SendCommand would surface that as an error
|
||||
// even though the reboot itself succeeded. Treat any short read or close
|
||||
// as "command was accepted".
|
||||
resp, err := t.SendCommand("sys reboot")
|
||||
if err != nil {
|
||||
// A read error after the write is the expected case (socket dies on
|
||||
// reboot). Only surface real transport failures; treat the rest as
|
||||
// success and let the caller verify by polling :8090/info.
|
||||
if isLikelyRebootCloseError(err) {
|
||||
return resp + "\n[connection closed by reboot]", nil
|
||||
}
|
||||
|
||||
return resp, fmt.Errorf("failed to send sys reboot: %w", err)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// isLikelyRebootCloseError returns true if err looks like the socket closed
|
||||
// because the device started rebooting, rather than a real connectivity
|
||||
// problem. We are intentionally generous here: the user already opted into
|
||||
// rebooting, so a closed socket is expected.
|
||||
func isLikelyRebootCloseError(err error) bool {
|
||||
msg := err.Error()
|
||||
for _, marker := range []string{"EOF", "closed", "connection reset", "broken pipe", "timed out"} {
|
||||
if strings.Contains(msg, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// TestDomain is the fake domain used for preliminary redirection tests.
|
||||
const TestDomain = "custom-test-api.bose.fake"
|
||||
|
||||
|
||||
@@ -946,7 +946,7 @@ func TestReboot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
_, err := m.Reboot("192.168.1.10")
|
||||
_, err := m.Reboot("192.168.1.10", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Reboot failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// telnetURLConfigCommands returns the canonical sequence of telnet commands
|
||||
// that point a SoundTouch device at the given local-service base URL.
|
||||
//
|
||||
// Order matters: `sys configuration …` writes the runtime URL, while
|
||||
// `envswitch boseurls set …` writes a parallel persistence layer that
|
||||
// otherwise wins on the next reboot. See docs/analysis/TELNET-MIGRATION-METHOD.md
|
||||
// §2.1 for the discussion this is derived from.
|
||||
func telnetURLConfigCommands(targetURL string) []string {
|
||||
return []string{
|
||||
"sys configuration bmxRegistryUrl " + targetURL + "/bmx/registry/v1/services",
|
||||
"sys configuration statsServerUrl " + targetURL,
|
||||
"sys configuration margeServerUrl " + targetURL,
|
||||
"sys configuration swUpdateUrl " + targetURL + "/updates/soundtouch",
|
||||
"envswitch boseurls set " + targetURL + " " + targetURL + "/updates/soundtouch",
|
||||
}
|
||||
}
|
||||
|
||||
// migrateViaTelnet runs the URL-configuration sequence over the device's
|
||||
// port-17000 diagnostic shell. It writes configuration only — reboot is left
|
||||
// to the user, who triggers it via the existing reboot button (which now
|
||||
// accepts a method=telnet|ssh selector).
|
||||
//
|
||||
// The sequence aborts on the first non-OK response so we never half-write the
|
||||
// configuration; the caller can retry safely after fixing the underlying
|
||||
// issue (closed port, hardened firmware, etc.).
|
||||
func (m *Manager) migrateViaTelnet(deviceIP, targetURL string) (string, error) {
|
||||
if m.NewTelnet == nil {
|
||||
return "", errors.New("telnet migration not configured: Manager.NewTelnet is nil")
|
||||
}
|
||||
|
||||
var logs strings.Builder
|
||||
|
||||
t := m.NewTelnet(deviceIP)
|
||||
if err := t.Dial(); err != nil {
|
||||
return logs.String(), fmt.Errorf("telnet dial %s:17000 failed: %w", deviceIP, err)
|
||||
}
|
||||
|
||||
defer func() { _ = t.Close() }()
|
||||
|
||||
banner, _ := t.Probe()
|
||||
if banner != "" {
|
||||
fmt.Fprintf(&logs, "Telnet banner: %q\n", strings.TrimSpace(banner))
|
||||
}
|
||||
|
||||
for _, cmd := range telnetURLConfigCommands(targetURL) {
|
||||
resp, err := t.SendCommand(cmd)
|
||||
if err != nil {
|
||||
return logs.String(), fmt.Errorf("telnet command %q failed: %w", cmd, err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&logs, "→ %s\n%s\n", cmd, strings.TrimRight(resp, "\r\n"))
|
||||
|
||||
if isCommandNotFound(resp) {
|
||||
return logs.String(), fmt.Errorf("device rejected %q (firmware does not expose this command)", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
verify, err := t.SendCommand("getpdo CurrentSystemConfiguration")
|
||||
if err != nil {
|
||||
return logs.String(), fmt.Errorf("verification command failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&logs, "→ getpdo CurrentSystemConfiguration\n%s\n", strings.TrimRight(verify, "\r\n"))
|
||||
|
||||
if !strings.Contains(verify, targetURL) {
|
||||
return logs.String(), fmt.Errorf("verification failed: getpdo response does not contain %q (device may have rejected the new URLs)", targetURL)
|
||||
}
|
||||
|
||||
logs.WriteString("Telnet migration succeeded. Reboot the device to apply.\n")
|
||||
|
||||
return logs.String(), nil
|
||||
}
|
||||
|
||||
// isCommandNotFound returns true if the device's response to a command
|
||||
// indicates the command is not available on this firmware. Different firmware
|
||||
// builds use slightly different wording; we accept any of the observed
|
||||
// variants.
|
||||
func isCommandNotFound(resp string) bool {
|
||||
low := strings.ToLower(resp)
|
||||
|
||||
return strings.Contains(low, "command not found") ||
|
||||
strings.Contains(low, "unknown command") ||
|
||||
strings.Contains(low, "not implemented")
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeTelnet is a deterministic TelnetClient for unit tests. The responses
|
||||
// map keys on the exact command string; the value is what SendCommand
|
||||
// returns. Commands not in the map return "Command not found\n".
|
||||
type fakeTelnet struct {
|
||||
dialErr error
|
||||
banner string
|
||||
responses map[string]string
|
||||
// fail returns this error from SendCommand for the named command.
|
||||
fail map[string]error
|
||||
// commands records every command actually sent, in order, so tests can
|
||||
// assert on sequencing.
|
||||
commands []string
|
||||
}
|
||||
|
||||
func (f *fakeTelnet) Dial() error { return f.dialErr }
|
||||
func (f *fakeTelnet) Probe() (string, error) { return f.banner, nil }
|
||||
func (f *fakeTelnet) Close() error { return nil }
|
||||
|
||||
func (f *fakeTelnet) SendCommand(cmd string) (string, error) {
|
||||
f.commands = append(f.commands, cmd)
|
||||
|
||||
if err, ok := f.fail[cmd]; ok {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if resp, ok := f.responses[cmd]; ok {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
return "Command not found\n", nil
|
||||
}
|
||||
|
||||
func newFakeTelnetManager(f *fakeTelnet) *Manager {
|
||||
m := &Manager{
|
||||
ServerURL: "http://example:8000",
|
||||
NewTelnet: func(host string) TelnetClient { return f },
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func happyResponses(targetURL string) map[string]string {
|
||||
return map[string]string{
|
||||
"sys configuration bmxRegistryUrl " + targetURL + "/bmx/registry/v1/services": "OK\n",
|
||||
"sys configuration statsServerUrl " + targetURL: "OK\n",
|
||||
"sys configuration margeServerUrl " + targetURL: "OK\n",
|
||||
"sys configuration swUpdateUrl " + targetURL + "/updates/soundtouch": "OK\n",
|
||||
"envswitch boseurls set " + targetURL + " " + targetURL + "/updates/soundtouch": "OK\n",
|
||||
"getpdo CurrentSystemConfiguration": "margeServerUrl=" + targetURL + "\nbmxRegistryUrl=" + targetURL + "/bmx/registry/v1/services\n",
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateViaTelnet_HappyPath(t *testing.T) {
|
||||
target := "http://example:8000"
|
||||
f := &fakeTelnet{
|
||||
banner: "BoseShell\n-> ",
|
||||
responses: happyResponses(target),
|
||||
}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
logs, err := m.migrateViaTelnet("192.0.2.1", target)
|
||||
if err != nil {
|
||||
t.Fatalf("migrateViaTelnet: %v", err)
|
||||
}
|
||||
|
||||
wantOrder := []string{
|
||||
"sys configuration bmxRegistryUrl " + target + "/bmx/registry/v1/services",
|
||||
"sys configuration statsServerUrl " + target,
|
||||
"sys configuration margeServerUrl " + target,
|
||||
"sys configuration swUpdateUrl " + target + "/updates/soundtouch",
|
||||
"envswitch boseurls set " + target + " " + target + "/updates/soundtouch",
|
||||
"getpdo CurrentSystemConfiguration",
|
||||
}
|
||||
|
||||
if len(f.commands) != len(wantOrder) {
|
||||
t.Fatalf("sent %d commands, want %d:\n%v", len(f.commands), len(wantOrder), f.commands)
|
||||
}
|
||||
|
||||
for i, want := range wantOrder {
|
||||
if f.commands[i] != want {
|
||||
t.Errorf("command[%d] = %q, want %q", i, f.commands[i], want)
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.Contains(logs, "succeeded") {
|
||||
t.Errorf("logs missing success marker:\n%s", logs)
|
||||
}
|
||||
|
||||
if !strings.Contains(logs, "BoseShell") {
|
||||
t.Errorf("logs missing banner echo:\n%s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateViaTelnet_DialFailureReturnsError(t *testing.T) {
|
||||
f := &fakeTelnet{dialErr: errors.New("connection refused")}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
_, err := m.migrateViaTelnet("192.0.2.1", "http://example:8000")
|
||||
if err == nil {
|
||||
t.Fatal("expected dial error, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "connection refused") {
|
||||
t.Errorf("err = %v, want to wrap connection refused", err)
|
||||
}
|
||||
|
||||
if len(f.commands) != 0 {
|
||||
t.Errorf("expected no commands sent on dial failure, got %v", f.commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateViaTelnet_CommandNotFoundAborts(t *testing.T) {
|
||||
target := "http://example:8000"
|
||||
resp := happyResponses(target)
|
||||
// The ST20-Portable case: `envswitch` is not implemented.
|
||||
delete(resp, "envswitch boseurls set "+target+" "+target+"/updates/soundtouch")
|
||||
|
||||
f := &fakeTelnet{responses: resp}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
_, err := m.migrateViaTelnet("192.0.2.1", target)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when envswitch is rejected, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "envswitch") {
|
||||
t.Errorf("err = %v, want to mention the rejected command", err)
|
||||
}
|
||||
|
||||
// The verification command must NOT have been sent — the run aborts on
|
||||
// the first rejection.
|
||||
for _, c := range f.commands {
|
||||
if c == "getpdo CurrentSystemConfiguration" {
|
||||
t.Errorf("verification was sent after a rejected command: %v", f.commands)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateViaTelnet_VerifyMismatchFails(t *testing.T) {
|
||||
target := "http://example:8000"
|
||||
resp := happyResponses(target)
|
||||
// Device echoes the OLD URLs (envswitch/sys configuration silently dropped).
|
||||
resp["getpdo CurrentSystemConfiguration"] = "margeServerUrl=https://streaming.bose.com\n"
|
||||
|
||||
f := &fakeTelnet{responses: resp}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
_, err := m.migrateViaTelnet("192.0.2.1", target)
|
||||
if err == nil {
|
||||
t.Fatal("expected verification mismatch error, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "verification failed") {
|
||||
t.Errorf("err = %v, want to mention verification failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateViaTelnet_TransportErrorAborts(t *testing.T) {
|
||||
target := "http://example:8000"
|
||||
f := &fakeTelnet{
|
||||
responses: happyResponses(target),
|
||||
fail: map[string]error{
|
||||
"sys configuration margeServerUrl " + target: errors.New("write: broken pipe"),
|
||||
},
|
||||
}
|
||||
m := newFakeTelnetManager(f)
|
||||
|
||||
_, err := m.migrateViaTelnet("192.0.2.1", target)
|
||||
if err == nil {
|
||||
t.Fatal("expected transport error, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "broken pipe") {
|
||||
t.Errorf("err = %v, want to wrap broken pipe", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateViaTelnet_MissingNewTelnetIsClearError(t *testing.T) {
|
||||
m := &Manager{ServerURL: "http://example:8000"} // NewTelnet deliberately nil
|
||||
|
||||
_, err := m.migrateViaTelnet("192.0.2.1", "http://example:8000")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when NewTelnet is nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "NewTelnet") {
|
||||
t.Errorf("err = %v, want a configuration error mentioning NewTelnet", err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,465 +0,0 @@
|
||||
/* ── 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.
|
Before Width: | Height: | Size: 859 B |
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 381 B |
@@ -1,24 +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 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>
|
||||
@@ -1,40 +0,0 @@
|
||||
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),
|
||||
}),
|
||||
};
|
||||
@@ -1,141 +0,0 @@
|
||||
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'));
|
||||
@@ -1,80 +0,0 @@
|
||||
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>
|
||||
`;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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>`
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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>
|
||||
`;
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
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>
|
||||
`;
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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>
|
||||
`;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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>
|
||||
`;
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
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>
|
||||
`;
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
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>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Package telnet provides a minimal line-oriented client for the SoundTouch
|
||||
// device's diagnostic shell on TCP port 17000.
|
||||
//
|
||||
// The protocol observed in the wild is a plain TCP stream with no Telnet
|
||||
// option negotiation (no IAC sequences), so the client uses the standard
|
||||
// library's net package directly. All I/O is deadline-driven so a wedged
|
||||
// device can never stall the caller indefinitely.
|
||||
package telnet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Default values for a fresh Client.
|
||||
const (
|
||||
DefaultPort = 17000
|
||||
DefaultDialTimeout = 2 * time.Second
|
||||
DefaultReadTimeout = 5 * time.Second
|
||||
DefaultWriteTimeout = 2 * time.Second
|
||||
// idleWindow is how long we wait for further bytes after the first
|
||||
// byte of a response before treating the response as complete.
|
||||
idleWindow = 400 * time.Millisecond
|
||||
)
|
||||
|
||||
// Client is a connected (or about-to-be-connected) session to a SoundTouch
|
||||
// diagnostic shell. A Client is not safe for concurrent use; create one per
|
||||
// device interaction.
|
||||
type Client struct {
|
||||
Host string
|
||||
Port int
|
||||
DialTimeout time.Duration
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
|
||||
conn net.Conn
|
||||
}
|
||||
|
||||
// NewClient returns a Client targeting host:17000 with the default timeouts.
|
||||
func NewClient(host string) *Client {
|
||||
return &Client{
|
||||
Host: host,
|
||||
Port: DefaultPort,
|
||||
DialTimeout: DefaultDialTimeout,
|
||||
ReadTimeout: DefaultReadTimeout,
|
||||
WriteTimeout: DefaultWriteTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
// Dial establishes the TCP connection. Subsequent calls are a no-op as long
|
||||
// as the existing connection is still open.
|
||||
func (c *Client) Dial() error {
|
||||
if c.conn != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
|
||||
|
||||
conn, err := net.DialTimeout("tcp", addr, c.DialTimeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %s: %w", addr, err)
|
||||
}
|
||||
|
||||
c.conn = conn
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close terminates the TCP connection. Calling Close on a closed Client is a
|
||||
// no-op.
|
||||
func (c *Client) Close() error {
|
||||
if c.conn == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := c.conn.Close()
|
||||
c.conn = nil
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Probe reads any banner the device emits immediately after connect. It
|
||||
// returns whatever bytes arrive within a short window; an empty banner is
|
||||
// not treated as an error because some firmware revisions stay silent until
|
||||
// the first command.
|
||||
func (c *Client) Probe() (string, error) {
|
||||
if c.conn == nil {
|
||||
return "", errors.New("telnet: not connected")
|
||||
}
|
||||
|
||||
if err := c.conn.SetReadDeadline(time.Now().Add(idleWindow * 2)); err != nil {
|
||||
return "", fmt.Errorf("set read deadline: %w", err)
|
||||
}
|
||||
|
||||
buf := make([]byte, 1024)
|
||||
|
||||
n, err := c.conn.Read(buf)
|
||||
if err != nil && !errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
return "", fmt.Errorf("read banner: %w", err)
|
||||
}
|
||||
|
||||
return string(buf[:n]), nil
|
||||
}
|
||||
|
||||
// SendCommand writes cmd followed by CRLF and reads the device's response.
|
||||
// The read terminates when the connection has been idle for idleWindow after
|
||||
// the first byte arrived, or when the overall ReadTimeout is reached.
|
||||
//
|
||||
// Returns the raw response text (callers decide what counts as success — the
|
||||
// device's textual conventions vary by firmware: some commands return "OK",
|
||||
// others echo state, others return nothing).
|
||||
func (c *Client) SendCommand(cmd string) (string, error) {
|
||||
if c.conn == nil {
|
||||
return "", errors.New("telnet: not connected")
|
||||
}
|
||||
|
||||
if err := c.conn.SetWriteDeadline(time.Now().Add(c.WriteTimeout)); err != nil {
|
||||
return "", fmt.Errorf("set write deadline: %w", err)
|
||||
}
|
||||
|
||||
if _, err := c.conn.Write([]byte(cmd + "\r\n")); err != nil {
|
||||
return "", fmt.Errorf("write %q: %w", cmd, err)
|
||||
}
|
||||
|
||||
overall := time.Now().Add(c.ReadTimeout)
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
chunk := make([]byte, 1024)
|
||||
haveBytes := false
|
||||
|
||||
for {
|
||||
deadline := overall
|
||||
|
||||
if haveBytes {
|
||||
d := time.Now().Add(idleWindow)
|
||||
if d.Before(overall) {
|
||||
deadline = d
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.conn.SetReadDeadline(deadline); err != nil {
|
||||
return buf.String(), fmt.Errorf("set read deadline: %w", err)
|
||||
}
|
||||
|
||||
n, err := c.conn.Read(chunk)
|
||||
if n > 0 {
|
||||
buf.Write(chunk[:n])
|
||||
|
||||
haveBytes = true
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
if haveBytes {
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
return buf.String(), fmt.Errorf("timed out waiting for response to %q", cmd)
|
||||
}
|
||||
|
||||
return buf.String(), fmt.Errorf("read after %q: %w", cmd, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package telnet
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// scriptedServer is a minimal mock of the device's port-17000 shell. It
|
||||
// returns the supplied banner on connect, then for each line read it emits
|
||||
// the corresponding entry from responses (or "Command not found" if the line
|
||||
// is not in the map).
|
||||
type scriptedServer struct {
|
||||
t *testing.T
|
||||
listener net.Listener
|
||||
banner string
|
||||
responses map[string]string
|
||||
// hangAfter, if non-empty, names a command after which the server stops
|
||||
// responding (to exercise the read-timeout path).
|
||||
hangAfter string
|
||||
// closeAfter, if non-empty, names a command after which the server closes
|
||||
// the connection mid-stream.
|
||||
closeAfter string
|
||||
|
||||
stop chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func newScriptedServer(t *testing.T, banner string, responses map[string]string) *scriptedServer {
|
||||
t.Helper()
|
||||
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
s := &scriptedServer{
|
||||
t: t,
|
||||
listener: l,
|
||||
banner: banner,
|
||||
responses: responses,
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
|
||||
s.wg.Add(1)
|
||||
|
||||
go s.serve()
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *scriptedServer) addr() string {
|
||||
return s.listener.Addr().String()
|
||||
}
|
||||
|
||||
func (s *scriptedServer) hostPort() (string, int) {
|
||||
host, portStr, err := net.SplitHostPort(s.addr())
|
||||
if err != nil {
|
||||
s.t.Fatalf("split host/port: %v", err)
|
||||
}
|
||||
|
||||
port := 0
|
||||
|
||||
if _, err := parseInt(portStr, &port); err != nil {
|
||||
s.t.Fatalf("parse port %q: %v", portStr, err)
|
||||
}
|
||||
|
||||
return host, port
|
||||
}
|
||||
|
||||
func (s *scriptedServer) close() {
|
||||
close(s.stop)
|
||||
_ = s.listener.Close()
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
func (s *scriptedServer) serve() {
|
||||
defer s.wg.Done()
|
||||
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
if s.banner != "" {
|
||||
_, _ = conn.Write([]byte(s.banner))
|
||||
}
|
||||
|
||||
r := bufio.NewReader(conn)
|
||||
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
cmd := strings.TrimRight(line, "\r\n")
|
||||
if cmd == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if cmd == s.closeAfter {
|
||||
return
|
||||
}
|
||||
|
||||
resp, ok := s.responses[cmd]
|
||||
if !ok {
|
||||
resp = "Command not found\n"
|
||||
}
|
||||
|
||||
_, _ = conn.Write([]byte(resp))
|
||||
|
||||
if cmd == s.hangAfter {
|
||||
// Block until the server is closed; the client's read deadline
|
||||
// must fire before then.
|
||||
<-s.stop
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseInt is a tiny strconv.Atoi wrapper so we don't drag strconv into this file.
|
||||
func parseInt(s string, out *int) (int, error) {
|
||||
n := 0
|
||||
|
||||
for _, ch := range s {
|
||||
if ch < '0' || ch > '9' {
|
||||
return 0, errors.New("not a number")
|
||||
}
|
||||
|
||||
n = n*10 + int(ch-'0')
|
||||
}
|
||||
|
||||
*out = n
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func newClientFor(t *testing.T, s *scriptedServer) *Client {
|
||||
t.Helper()
|
||||
|
||||
host, port := s.hostPort()
|
||||
|
||||
c := NewClient(host)
|
||||
c.Port = port
|
||||
// Tighten the timeouts so tests fail fast if the implementation regresses.
|
||||
c.DialTimeout = 500 * time.Millisecond
|
||||
c.ReadTimeout = 1500 * time.Millisecond
|
||||
c.WriteTimeout = 500 * time.Millisecond
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func TestNewClient_Defaults(t *testing.T) {
|
||||
c := NewClient("192.168.1.10")
|
||||
if c.Host != "192.168.1.10" {
|
||||
t.Errorf("Host = %q, want 192.168.1.10", c.Host)
|
||||
}
|
||||
|
||||
if c.Port != DefaultPort {
|
||||
t.Errorf("Port = %d, want %d", c.Port, DefaultPort)
|
||||
}
|
||||
|
||||
if c.DialTimeout != DefaultDialTimeout {
|
||||
t.Errorf("DialTimeout = %v, want %v", c.DialTimeout, DefaultDialTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDial_Failure(t *testing.T) {
|
||||
// A reserved-for-test address that nothing should be listening on.
|
||||
c := NewClient("127.0.0.1")
|
||||
c.Port = 1 // privileged port, will not connect from a test
|
||||
c.DialTimeout = 200 * time.Millisecond
|
||||
|
||||
if err := c.Dial(); err == nil {
|
||||
t.Error("expected dial failure, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbe_ReturnsBanner(t *testing.T) {
|
||||
s := newScriptedServer(t, "BoseShell v1\n-> ", nil)
|
||||
defer s.close()
|
||||
|
||||
c := newClientFor(t, s)
|
||||
|
||||
if err := c.Dial(); err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
got, err := c.Probe()
|
||||
if err != nil {
|
||||
t.Fatalf("Probe: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(got, "BoseShell v1") {
|
||||
t.Errorf("Probe = %q, want to contain banner", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbe_NoBannerIsOK(t *testing.T) {
|
||||
s := newScriptedServer(t, "", nil)
|
||||
defer s.close()
|
||||
|
||||
c := newClientFor(t, s)
|
||||
|
||||
if err := c.Dial(); err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
got, err := c.Probe()
|
||||
if err != nil {
|
||||
t.Fatalf("Probe: %v", err)
|
||||
}
|
||||
|
||||
if got != "" {
|
||||
t.Errorf("Probe = %q, want empty when no banner is sent", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendCommand_HappyPath(t *testing.T) {
|
||||
s := newScriptedServer(t, "", map[string]string{
|
||||
"sys configuration bmxRegistryUrl http://example:8000/bmx/registry/v1/services": "OK\n",
|
||||
"sys configuration margeServerUrl http://example:8000": "OK\n",
|
||||
"getpdo CurrentSystemConfiguration": "margeServerUrl=http://example:8000\nbmxRegistryUrl=http://example:8000/bmx/registry/v1/services\n",
|
||||
})
|
||||
defer s.close()
|
||||
|
||||
c := newClientFor(t, s)
|
||||
|
||||
if err := c.Dial(); err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
resp, err := c.SendCommand("sys configuration margeServerUrl http://example:8000")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCommand: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(resp, "OK") {
|
||||
t.Errorf("response = %q, want to contain OK", resp)
|
||||
}
|
||||
|
||||
resp, err = c.SendCommand("getpdo CurrentSystemConfiguration")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCommand getpdo: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(resp, "margeServerUrl=http://example:8000") {
|
||||
t.Errorf("getpdo response = %q, want to echo configured url", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendCommand_CommandNotFound(t *testing.T) {
|
||||
s := newScriptedServer(t, "", map[string]string{})
|
||||
defer s.close()
|
||||
|
||||
c := newClientFor(t, s)
|
||||
|
||||
if err := c.Dial(); err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
resp, err := c.SendCommand("definitely not a real command")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCommand: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(resp, "Command not found") {
|
||||
t.Errorf("response = %q, want to contain 'Command not found'", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendCommand_DeadlineFiresWhenDeviceHangs(t *testing.T) {
|
||||
s := newScriptedServer(t, "", map[string]string{
|
||||
"first": "OK\n",
|
||||
"second": "",
|
||||
})
|
||||
s.hangAfter = "second"
|
||||
|
||||
defer s.close()
|
||||
|
||||
c := newClientFor(t, s)
|
||||
c.ReadTimeout = 600 * time.Millisecond
|
||||
|
||||
if err := c.Dial(); err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
if _, err := c.SendCommand("first"); err != nil {
|
||||
t.Fatalf("first SendCommand: %v", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
_, err := c.SendCommand("second")
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "timed out") {
|
||||
t.Errorf("err = %v, want timed-out wording", err)
|
||||
}
|
||||
|
||||
// The error must arrive within roughly the ReadTimeout, not after several
|
||||
// times that — guards against an accidental infinite read loop.
|
||||
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||
t.Errorf("SendCommand returned after %v, want under 2s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendCommand_ConnectionClosedMidStream(t *testing.T) {
|
||||
s := newScriptedServer(t, "", map[string]string{})
|
||||
s.closeAfter = "trigger close"
|
||||
|
||||
defer s.close()
|
||||
|
||||
c := newClientFor(t, s)
|
||||
|
||||
if err := c.Dial(); err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
_, err := c.SendCommand("trigger close")
|
||||
if err == nil {
|
||||
t.Fatal("expected error after server closes mid-stream, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendCommand_FailsWithoutDial(t *testing.T) {
|
||||
c := NewClient("127.0.0.1")
|
||||
if _, err := c.SendCommand("anything"); err == nil {
|
||||
t.Error("SendCommand without Dial should fail, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClose_IsIdempotent(t *testing.T) {
|
||||
s := newScriptedServer(t, "", nil)
|
||||
defer s.close()
|
||||
|
||||
c := newClientFor(t, s)
|
||||
|
||||
if err := c.Dial(); err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
|
||||
if err := c.Close(); err != nil {
|
||||
t.Errorf("first Close: %v", err)
|
||||
}
|
||||
|
||||
if err := c.Close(); err != nil {
|
||||
t.Errorf("second Close: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# On-Device Installer
|
||||
|
||||
Allows to run AfterTouch on SoundTouch devices directly, eliminating the need to run and maintain a separate server on the local network.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
### Invasiveness
|
||||
|
||||
AfterTouch usually normally migrates the SoundTouch devices very noninvasive, by changing the configuration of the device. Running AfterTouch on the device itself is slightly more invasive, because it needs to create a script that starts AfterTouch on boot.
|
||||
|
||||
### AfterTouch Availability
|
||||
|
||||
Some devices will expose the AfterTouch port, some won't. We currently (May 2026) suspect that the newer generation devices (those with Bluetooth) will expose the port, while the older ones won't. We're still investigating how to expose AfterTouch on all devices.
|
||||
|
||||
If your device doesn't expose the port, you can still use the on-device installer, but you'll need to run AfterTouch on each one of your speakers individually and may only access AfterTouch via ssh port forwarding. This will also make OAuth authentication a little more tricky, but should also work via SSH port forwarding.
|
||||
|
||||
### Space Limitation
|
||||
|
||||
The storage space on the SoundTouch devices is very limited. At the moment only one AfterTouch installation barely fits on them with enough room for the data it needs to maintain. When installing, make sure that you have removed any binaries and folders of previous installation attempts.
|
||||
|
||||
The space limitation also means we are currently unsure on how to update the system, because two binaries are already too large. We are currently working on this - both by checking how we can make the binaries smaller, but also on how we can extend the storage space (e.g. by running AfterTouch from a USB drive).
|
||||
|
||||
## Installation
|
||||
|
||||
Enable SSH on your SoundTouch device using the usual "Stick with remote_services" method. Connect with the following command.
|
||||
|
||||
```bash
|
||||
ssh -oHostKeyAlgorithms=+ssh-rsa root@<IP_ADDRESS_OF_SPEAKER>
|
||||
```
|
||||
|
||||
Then, run the following command to install AfterTouch on the device.
|
||||
|
||||
```bash
|
||||
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
|
||||
```
|
||||
|
||||
After the installation check if you can access AfterTouch from your local device by navigating to `http://<IP_ADDRESS_OF_SPEAKER>:8000`. If you can access the AfterTouch UI, you're good to go! If not, you may need to run AfterTouch on the speaker via SSH port forwarding.
|
||||
|
||||
```bash
|
||||
ssh -L 8000:localhost:8000 root@<IP_ADDRESS_OF_SPEAKER>
|
||||
```
|
||||
|
||||
## Updating AfterTouch
|
||||
|
||||
To update AfterTouch, simply run the installation command again. The installer will check if there's a new version available and update it if necessary.
|
||||
|
||||
## Uninstallation
|
||||
|
||||
Before uninstall, you might want to revert the migration, especially the changes to the server URLs (even though having configured an unresponsive local server probably is about as bad as having configured unresponsive Bose servers). To uninstall AfterTouch, run the following command on the speaker.
|
||||
|
||||
```bash
|
||||
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/uninstall.sh | sh
|
||||
```
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/bin/sh
|
||||
### BEGIN INIT INFO
|
||||
# Provides: aftertouch-service
|
||||
# Required-Start: $network $local_fs
|
||||
# Required-Stop: $network $local_fs
|
||||
# Default-Start: 2 3 4 5
|
||||
# Default-Stop: 0 1 6
|
||||
# Short-Description: Run AfterTouch on this device
|
||||
# Description: Start/stop AfterTouch soundtouch-service
|
||||
### END INIT INFO
|
||||
|
||||
|
||||
NAME="aftertouch-service"
|
||||
DESC="Bose AfterTouch service"
|
||||
DAEMON="/opt/aftertouch/aftertouch-service"
|
||||
PIDFILE="/var/run/$NAME.pid"
|
||||
DATADIR="/opt/aftertouch/data"
|
||||
SCRIPTNAME="/etc/init.d/$NAME"
|
||||
USER="root"
|
||||
|
||||
|
||||
# Export PATH
|
||||
export PATH="/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin"
|
||||
|
||||
|
||||
# Sanity check executable
|
||||
test -x "$DAEMON" || {
|
||||
echo "ERROR: Cannot execute $DAEMON (check path and permissions)." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
echo "Starting $DESC..."
|
||||
|
||||
mount -o remount,rw / >/dev/null 2>&1 || {
|
||||
echo "ERROR: remount failed." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
mkdir -p "$DATADIR"
|
||||
|
||||
start-stop-daemon --start \
|
||||
--quiet \
|
||||
--pidfile "$PIDFILE" \
|
||||
--background \
|
||||
--make-pidfile \
|
||||
--chuid "$USER" \
|
||||
--startas "/bin/sh" \
|
||||
-- -c "\"$DAEMON\" --data-dir '$DATADIR' --record-interactions=false --discovery-interval=60m"
|
||||
|
||||
tries=0
|
||||
max_tries=60
|
||||
while [ $tries -lt $max_tries ]; do
|
||||
if curl -fsS http://localhost:8000 >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
tries=$((tries + 1))
|
||||
done
|
||||
|
||||
exit 1
|
||||
;;
|
||||
|
||||
stop)
|
||||
echo "Stopping $DESC..."
|
||||
if [ -f "$PIDFILE" ]; then
|
||||
start-stop-daemon --stop \
|
||||
--quiet \
|
||||
--oknodo \
|
||||
--pidfile "$PIDFILE"
|
||||
rm -f "$PIDFILE"
|
||||
else
|
||||
echo "No $NAME running (no PID file)." >&2
|
||||
fi
|
||||
;;
|
||||
|
||||
restart|force-reload)
|
||||
"$0" stop
|
||||
sleep 2
|
||||
"$0" start
|
||||
;;
|
||||
|
||||
status)
|
||||
if [ -f "$PIDFILE" ]; then
|
||||
PID=$(cat "$PIDFILE")
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
echo "$NAME is running."
|
||||
else
|
||||
echo "$NAME is not running (PID file exists but process is dead)."
|
||||
fi
|
||||
else
|
||||
echo "$NAME is not running."
|
||||
fi
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Usage: $SCRIPTNAME {start|stop|restart|force-reload|status}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
VERSION=${VERSION:-0.73.0}
|
||||
GH_REPO=${GH_REPO:-gesellix/Bose-SoundTouch}
|
||||
BINARY_URL=${BINARY_URL:-https://github.com/$GH_REPO/releases/download/v$VERSION/soundtouch-service-v$VERSION-linux-armv7}
|
||||
INIT_SCRIPT_URL=${INIT_SCRIPT_URL:-https://raw.githubusercontent.com/$GH_REPO/v$VERSION/scripts/on-device-install/aftertouch}
|
||||
UPDATE_TMP_DIR=${UPDATE_TMP_DIR:-/media/aftertouch}
|
||||
|
||||
|
||||
rm -rf "$UPDATE_TMP_DIR" || true
|
||||
mkdir -p "$UPDATE_TMP_DIR"
|
||||
|
||||
echo "Installing Aftertouch $VERSION ..."
|
||||
mkdir -p /opt/aftertouch
|
||||
curl \
|
||||
-sSL \
|
||||
-o "$UPDATE_TMP_DIR/binary" \
|
||||
--fail \
|
||||
"$BINARY_URL"
|
||||
|
||||
mv "$UPDATE_TMP_DIR/binary" /opt/aftertouch/aftertouch-service
|
||||
chmod +x /opt/aftertouch/aftertouch-service
|
||||
|
||||
echo "Creating init script..."
|
||||
curl \
|
||||
-sSL \
|
||||
-o "$UPDATE_TMP_DIR/init-script" \
|
||||
--fail \
|
||||
"$INIT_SCRIPT_URL"
|
||||
|
||||
mv "$UPDATE_TMP_DIR/init-script" /etc/init.d/aftertouch
|
||||
chmod +x /etc/init.d/aftertouch
|
||||
update-rc.d aftertouch defaults
|
||||
|
||||
echo "Installation complete. Running initial startup to accelerate future startups..."
|
||||
/etc/init.d/aftertouch start
|
||||
|
||||
/etc/init.d/aftertouch status
|
||||
|
||||
echo "Installation complete. Aftertouch $VERSION is now running on your device."
|
||||
echo "You can try to connect to at http://<your-device-ip>:8000 ."
|
||||
echo "If the connection fails, reconnect ssh with port forwarding like:"
|
||||
echo "ssh -L 8000:localhost:8000 root@<IP_ADDRESS_OF_SPEAKER>"
|
||||
@@ -0,0 +1,4 @@
|
||||
/etc/init.d/aftertouch stop
|
||||
rm -rf /etc/init.d/aftertouch
|
||||
update-rc.d -f aftertouch remove
|
||||
rm -rf /opt/aftertouch
|
||||
Reference in New Issue
Block a user