Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1288a619f7 | ||
|
|
ec8bbb2f86 | ||
|
|
e75e2bea0c | ||
|
|
dd5aa2ad53 | ||
|
|
aced0f3f81 | ||
|
|
a886518cad | ||
|
|
dc81b0aa81 | ||
|
|
c648027735 | ||
|
|
fced88a8a6 | ||
|
|
0ee673c097 | ||
|
|
395b2fec8e | ||
|
|
be7e44e14b | ||
|
|
a87783d8c6 | ||
|
|
be017440b7 | ||
|
|
10de011c18 | ||
|
|
f7b74db3ea | ||
|
|
72d75133c4 | ||
|
|
e4c12471b4 | ||
|
|
3329149282 | ||
|
|
523ff0eb17 | ||
|
|
025e15d65c | ||
|
|
7d140b3e2a | ||
|
|
69210638e5 | ||
|
|
6aef2b807d | ||
|
|
95f5e9c831 | ||
|
|
7337296ae9 | ||
|
|
92a5d3592c | ||
|
|
2f04af872b | ||
|
|
9479d6d11d | ||
|
|
f687ba0d82 | ||
|
|
ab2bf0731a | ||
|
|
cafaba1be0 | ||
|
|
93082d2cdc | ||
|
|
087006c483 | ||
|
|
b7013a5ec8 | ||
|
|
7d76b3fab2 | ||
|
|
6ca206053f | ||
|
|
090eb162fb | ||
|
|
972824e07f | ||
|
|
1e2148d53b | ||
|
|
9a070da1ef | ||
|
|
d4b518da23 | ||
|
|
89bafd97b6 | ||
|
|
d616bc09fd | ||
|
|
8af60c7e4b | ||
|
|
8a21db3517 | ||
|
|
742484568e | ||
|
|
ed2d8680e4 | ||
|
|
6dc8c23f04 | ||
|
|
fa57ee9574 | ||
|
|
e438db05d9 | ||
|
|
8c02a009dc | ||
|
|
f20cfcb319 | ||
|
|
a453059d6d | ||
|
|
505e6dd760 | ||
|
|
735187cae8 | ||
|
|
e8622cc382 | ||
|
|
c59052bdb4 | ||
|
|
15a6c4b0a0 | ||
|
|
ae3a3765db | ||
|
|
59019cf55c | ||
|
|
5e612e57ec | ||
|
|
02026a9f3a | ||
|
|
aaf067088a | ||
|
|
358ea18138 | ||
|
|
0c5c1803a5 | ||
|
|
cdf80a793e | ||
|
|
b511e052e2 | ||
|
|
b7197a8679 | ||
|
|
5bfc24b7fb | ||
|
|
1e61adbb46 | ||
|
|
b8ab4b5723 | ||
|
|
5da7e001b2 | ||
|
|
5269c05e56 |
@@ -41,3 +41,20 @@ PREFERRED_DEVICES="Living Room@192.168.1.100:8090;Kitchen@192.168.1.101;192.168.
|
||||
# Alternative format examples:
|
||||
# PREFERRED_DEVICES="192.168.178.35;192.168.178.28"
|
||||
# PREFERRED_DEVICES="SoundTouch 10@192.168.178.35;SoundTouch 20@192.168.178.28"
|
||||
|
||||
# Spotify Integration
|
||||
# Create an app at https://developer.spotify.com/dashboard
|
||||
# SPOTIFY_CLIENT_ID=your_client_id
|
||||
# SPOTIFY_CLIENT_SECRET=your_client_secret
|
||||
# Auth confirmation url using GET, works in browsers
|
||||
# SPOTIFY_REDIRECT_URI=https://your-server.example.com/mgmt/spotify/callback
|
||||
# Auth confirmation url using POST, works with the ueberboese-app (https://github.com/julius-d/ueberboese-app)
|
||||
# SPOTIFY_REDIRECT_URI=https://your-server.example.com/mgmt/spotify/confirm
|
||||
|
||||
# Management API Authentication
|
||||
# Protects /mgmt/* endpoints (Spotify token access, account management)
|
||||
MGMT_USERNAME=admin
|
||||
MGMT_PASSWORD=change_me!
|
||||
|
||||
# External base URL (required when behind a reverse proxy for OAuth callbacks)
|
||||
# BASE_URL=https://your-server.example.com
|
||||
|
||||
@@ -149,8 +149,8 @@ jobs:
|
||||
# Check that all documented endpoints exist in code
|
||||
echo "Validating API documentation consistency..."
|
||||
|
||||
# Extract endpoint patterns from cookbook
|
||||
if [ -f "docs/API-COOKBOOK.md" ]; then
|
||||
# Check API cookbook
|
||||
if [ -f "docs/reference/API-COOKBOOK.md" ]; then
|
||||
echo "✓ API Cookbook exists"
|
||||
else
|
||||
echo "✗ API Cookbook missing"
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
fi
|
||||
|
||||
# Check getting started guide
|
||||
if [ -f "docs/GETTING-STARTED.md" ]; then
|
||||
if [ -f "docs/guides/GETTING-STARTED.md" ]; then
|
||||
echo "✓ Getting Started guide exists"
|
||||
else
|
||||
echo "✗ Getting Started guide missing"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Deploy Documentation
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
- name: Build with Jekyll
|
||||
uses: actions/jekyll-build-pages@v1
|
||||
with:
|
||||
source: 'docs/'
|
||||
destination: '_site'
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: '_site'
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -133,10 +133,13 @@ jobs:
|
||||
local CMD_PATH=$2
|
||||
local OUTPUT_NAME
|
||||
|
||||
# Ensure build directory exists
|
||||
mkdir -p build
|
||||
|
||||
if [[ "${{ matrix.goos }}" == "windows" ]]; then
|
||||
OUTPUT_NAME="${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}.exe"
|
||||
OUTPUT_NAME="build/${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}.exe"
|
||||
else
|
||||
OUTPUT_NAME="${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}"
|
||||
OUTPUT_NAME="build/${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}"
|
||||
fi
|
||||
|
||||
echo "Building $BINARY_NAME: $OUTPUT_NAME"
|
||||
@@ -193,8 +196,8 @@ jobs:
|
||||
with:
|
||||
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
|
||||
path: |
|
||||
soundtouch-cli-v*
|
||||
soundtouch-service-v*
|
||||
build/soundtouch-cli-v*
|
||||
build/soundtouch-service-v*
|
||||
retention-days: 1
|
||||
|
||||
checksums:
|
||||
|
||||
@@ -19,6 +19,7 @@ dist/
|
||||
/example-unified
|
||||
/mdns-scanner
|
||||
/websocket-demo
|
||||
/main
|
||||
|
||||
# Environment configuration
|
||||
.env
|
||||
@@ -28,6 +29,7 @@ docker-compose.override.yml
|
||||
|
||||
# Test coverage reports
|
||||
coverage.out
|
||||
coverage*.out
|
||||
coverage.html
|
||||
*.prof
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ When filing a bug report, include:
|
||||
Feature requests are welcome! Please:
|
||||
|
||||
1. **Check if the feature already exists** in documentation
|
||||
2. **Verify it's supported by the SoundTouch API** (see [official API docs](docs/API-Endpoints-Overview.md))
|
||||
2. **Verify it's supported by the SoundTouch API** (see [official API docs](docs/reference/API-ENDPOINTS.md))
|
||||
3. **Explain the use case** and how it benefits users
|
||||
|
||||
### 🔧 Contributing Code
|
||||
@@ -469,10 +469,10 @@ Contributors will be:
|
||||
|
||||
- [Go Documentation](https://golang.org/doc/)
|
||||
- [Effective Go](https://golang.org/doc/effective_go.html)
|
||||
- [Bose SoundTouch API Documentation](docs/API-Endpoints-Overview.md)
|
||||
- [Bose SoundTouch API Documentation](docs/reference/API-ENDPOINTS.md)
|
||||
- [Project Architecture](docs/PROJECT-PATTERNS.md)
|
||||
- [Development Status](docs/STATUS.md)
|
||||
- [Development Status](docs/archive/STATUS.md)
|
||||
|
||||
---
|
||||
|
||||
**Thank you for contributing!** Every contribution helps make this library better for the entire SoundTouch community.
|
||||
**Thank you for contributing!** Every contribution helps make this library better for the entire SoundTouch community.
|
||||
|
||||
@@ -17,12 +17,16 @@ A comprehensive solution for controlling and preserving Bose SoundTouch devices,
|
||||
- ⚡ **Real-time Events**: WebSocket connection for live device state monitoring
|
||||
- 🔍 **Device Discovery**: Automatic discovery via UPnP/SSDP and mDNS
|
||||
- 📻 **Content Navigation**: Browse and search TuneIn, Pandora, Spotify, local music
|
||||
- 📻 **RadioBrowser**: Access thousands of internet radio stations via [radio-browser.info](docs/reference/radio-browser.md)
|
||||
- 🎙️ **Station Management**: Add and play radio stations without presets
|
||||
- 🖥️ **CLI Tool**: Comprehensive command-line interface
|
||||
- 🌐 **SoundTouch Service**: Emulate Bose cloud services for offline device operation
|
||||
- 🔧 **Service Migration**: Migrate devices to use local services instead of Bose cloud
|
||||
- 🔧 **Service Migration**: Migrate devices to use local services instead of Bose cloud (XML, Hosts, or DNS redirection)
|
||||
- 🔍 **DNS Discovery & Interception**: Dynamic DNS server for intercepting and logging Bose service queries (requires port 53)
|
||||
- 📊 **DNS Discovery Analysis**: Track and deduplicate all device DNS queries to discover hidden hostnames
|
||||
- 📊 **Traffic Analysis**: Proxy and log device communications
|
||||
- 📝 **HTTP Recording**: Persist interactions as re-playable `.http` files
|
||||
- 🧹 **Session Management**: Manage and cleanup recorded interaction sessions
|
||||
- 🔒 **Production Ready**: Extensive testing with real SoundTouch hardware
|
||||
- 🌐 **Cross-Platform**: Windows, macOS, Linux support
|
||||
|
||||
@@ -61,7 +65,7 @@ soundtouch-cli --host 192.168.1.100 volume set --level 50
|
||||
soundtouch-cli --host 192.168.1.100 preset list
|
||||
```
|
||||
|
||||
For full CLI documentation, see [docs/guides/CLI-REFERENCE.md](docs/guides/CLI-REFERENCE.md).
|
||||
For full CLI documentation, see the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html).
|
||||
|
||||
### SoundTouch Service (Cloud Shutdown Protection)
|
||||
|
||||
@@ -72,8 +76,10 @@ The `soundtouch-service` is a local server that emulates Bose's cloud services.
|
||||
- **🔌 Easy Setup**: Activate SSH via USB stick (`remote_services` file)
|
||||
- **🔧 Device Migration**: Seamlessly transition devices to local control
|
||||
- **🌐 Web Management UI**: Easy browser-based setup and management
|
||||
- **🎮 Stockholm Mini**: A minimal reverse-engineered UI for device control (accessible at `/web/stockholm-mini/`)
|
||||
- **💾 Persistent Data**: Store presets, recents, and sources locally
|
||||
- **📝 HTTP Recording**: Persist all interactions as re-playable `.http` files
|
||||
- **🧹 Session Management**: Manage and cleanup recorded interaction sessions
|
||||
|
||||
#### Quick Start:
|
||||
```bash
|
||||
@@ -82,11 +88,11 @@ soundtouch-service
|
||||
```
|
||||
Open `http://localhost:8000` in your browser to manage your devices. Documentation is also available directly through the web interface.
|
||||
|
||||
For a comprehensive guide on transitioning your system, see the [Bose Cloud Shutdown: Survival Guide](docs/guides/SURVIVAL-GUIDE.md).
|
||||
For a comprehensive guide on transitioning your system, see the [Bose Cloud Shutdown: Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html).
|
||||
|
||||
Detailed service configuration and Docker instructions can be found in [docs/guides/SOUNDTOUCH-SERVICE.md](docs/guides/SOUNDTOUCH-SERVICE.md).
|
||||
Detailed service configuration and Docker instructions can be found in [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SOUNDTOUCH-SERVICE.html).
|
||||
|
||||
For professional migration tips and safety measures, see the [Migration & Safety Guide](docs/guides/MIGRATION-SAFETY.md).
|
||||
For professional migration tips and safety measures, see the [Migration & Safety Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html).
|
||||
|
||||
### Library Usage
|
||||
|
||||
@@ -315,8 +321,8 @@ func main() {
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
// Play Text-to-Speech message
|
||||
err := c.PlayTTS("Welcome home!", "your-app-key", 70)
|
||||
// Play Text-to-Speech message (language code "EN", "DE", etc.)
|
||||
err := c.PlayTTS("Welcome home!", "your-app-key", "EN", 70)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -379,19 +385,19 @@ This library supports all Bose SoundTouch-compatible devices, including:
|
||||
## Documentation
|
||||
|
||||
- 📖 [Contributing Guide](CONTRIBUTING.md) - How to contribute to the project
|
||||
- 📚 [API Reference](docs/API-Endpoints-Overview.md) - Complete endpoint documentation
|
||||
- 🔧 [CLI Reference](docs/CLI-REFERENCE.md) - Command-line tool guide
|
||||
- 🌐 [SoundTouch Service Guide](docs/SOUNDTOUCH-SERVICE.md) - Local service setup and migration
|
||||
- 🎯 [Getting Started](docs/GETTING-STARTED.md) - Detailed setup and usage
|
||||
- 📻 [Preset Quick Start](docs/PRESET-QUICKSTART.md) - Favorite content management
|
||||
- 🧭 [Navigation Guide](docs/NAVIGATION-GUIDE.md) - Content browsing and station management
|
||||
- 📋 [Navigation API Reference](docs/API-NAVIGATION-REFERENCE.md) - Navigation API documentation
|
||||
- ⚙️ [Advanced Features](docs/SYSTEM-ENDPOINTS.md) - Advanced functionality
|
||||
- 🏠 [Multiroom Setup](docs/zone-management.md) - Zone configuration guide
|
||||
- ⚡ [WebSocket Events](docs/websocket-events.md) - Real-time event handling
|
||||
- 🔔 [Speaker Notifications](docs/SPEAKER_ENDPOINT.md) - TTS and audio notifications guide
|
||||
- 🔍 [Device Discovery](docs/DISCOVERY.md) - Discovery configuration
|
||||
- 🛠️ [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
|
||||
- 📚 [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API-ENDPOINTS.html) - Complete endpoint documentation
|
||||
- 🔧 [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html) - Command-line tool guide
|
||||
- 🌐 [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SOUNDTOUCH-SERVICE.html) - Local service setup and migration
|
||||
- 🎯 [Getting Started](https://gesellix.github.io/Bose-SoundTouch/guides/GETTING-STARTED.html) - Detailed setup and usage
|
||||
- 📻 [Preset Quick Start](https://gesellix.github.io/Bose-SoundTouch/PRESET-QUICKSTART.md) - Favorite content management
|
||||
- 🧭 [Navigation Guide](https://gesellix.github.io/Bose-SoundTouch/NAVIGATION-GUIDE.md) - Content browsing and station management
|
||||
- 📋 [Navigation API Reference](https://gesellix.github.io/Bose-SoundTouch/API-NAVIGATION-REFERENCE.md) - Navigation API documentation
|
||||
- ⚙️ [Advanced Features](https://gesellix.github.io/Bose-SoundTouch/reference/SYSTEM-ENDPOINTS.html) - Advanced functionality
|
||||
- 🏠 [Multiroom Setup](https://gesellix.github.io/Bose-SoundTouch/reference/ZONE-MANAGEMENT.html) - Zone configuration guide
|
||||
- ⚡ [WebSocket Events](https://gesellix.github.io/Bose-SoundTouch/reference/WEBSOCKET-EVENTS.html) - Real-time event handling
|
||||
- 🔔 [Speaker Notifications](https://gesellix.github.io/Bose-SoundTouch/reference/SPEAKER-ENDPOINT.html) - TTS and audio notifications guide
|
||||
- 🔍 [Device Discovery](https://gesellix.github.io/Bose-SoundTouch/reference/DISCOVERY.html) - Discovery configuration
|
||||
- 🛠️ [Troubleshooting](https://gesellix.github.io/Bose-SoundTouch/guides/TROUBLESHOOTING.html) - Common issues and solutions
|
||||
|
||||
## Development
|
||||
|
||||
@@ -479,7 +485,7 @@ This project builds upon the excellent work of several community projects:
|
||||
### SoundCork 🍾
|
||||
- **Project**: [SoundCork - SoundTouch API Intercept](https://github.com/deborahgu/soundcork)
|
||||
- **Authors**: Deborah Kaplan and contributors
|
||||
- **Our Implementation**: The `soundtouch-service` in this project is heavily inspired by and based on SoundCork's Python implementation. SoundCork pioneered the approach of intercepting and emulating Bose's cloud services, providing the foundation for offline SoundTouch operation.
|
||||
- **Our Implementation**: The `soundtouch-service` in this project is heavily inspired by SoundCork's Python implementation. SoundCork pioneered the approach of intercepting and emulating Bose's cloud services, providing the foundation for offline SoundTouch operation.
|
||||
- **Key Contributions**: Service emulation architecture, BMX/Marge endpoint discovery, device migration strategies
|
||||
- **License**: MIT License
|
||||
|
||||
@@ -526,13 +532,13 @@ If you discover new endpoints, features, or improvements through this library, p
|
||||
- 🐛 **Bug Reports**: [Create an issue](https://github.com/gesellix/bose-soundtouch/issues/new)
|
||||
- 💡 **Feature Requests**: [Start a discussion](https://github.com/gesellix/bose-soundtouch/discussions)
|
||||
- ❓ **Questions**: Check [existing discussions](https://github.com/gesellix/bose-soundtouch/discussions)
|
||||
- 📖 **Documentation**: Browse the [docs/](docs/) directory
|
||||
- 🔍 **New Discoveries**: See [Undocumented Community Features](docs/UNDOCUMENTED-COMMUNITY-FEATURES.md) for advanced API research
|
||||
- 🌐 **Upstream Analysis**: [Upstream URLs & Domains](docs/UPSTREAM-URLS-ANALYSIS.md) for cloud dependency research
|
||||
- 🔧 **Redirection Guide**: [Device Redirect Methods](docs/DEVICE-REDIRECT-METHODS.md) for custom service setup
|
||||
- 🐣 **Initial Setup**: [Device Initial Setup Variants](docs/DEVICE-INITIAL-SETUP.md) for out-of-the-box configuration
|
||||
- 📜 **Logging & Debugging**: [Device Logging Guide](docs/DEVICE-LOGGING.md) for accessing system and traffic logs
|
||||
- 🔒 **HTTPS & CA Setup**: [HTTPS & Custom CA Guide](docs/HTTPS-SETUP.md) for secure `/etc/hosts` redirection
|
||||
- 📖 **Documentation**: [Online Documentation](https://gesellix.github.io/Bose-SoundTouch/)
|
||||
- 🔍 **New Discoveries**: [Undocumented Community Features](https://gesellix.github.io/Bose-SoundTouch/UNDOCUMENTED-COMMUNITY-FEATURES.md)
|
||||
- 🌐 **Upstream Analysis**: [Upstream URLs & Domains](https://gesellix.github.io/Bose-SoundTouch/analysis/UPSTREAM-URLS.html)
|
||||
- 🔧 **Redirection Guide**: [Device Redirect Methods](https://gesellix.github.io/Bose-SoundTouch/analysis/DEVICE-REDIRECT-METHODS.html)
|
||||
- 🐣 **Initial Setup**: [Device Initial Setup Variants](https://gesellix.github.io/Bose-SoundTouch/guides/DEVICE-INITIAL-SETUP.html)
|
||||
- 📜 **Logging & Debugging**: [Device Logging Guide](https://gesellix.github.io/Bose-SoundTouch/DEVICE-LOGGING.md)
|
||||
- 🔒 **HTTPS & CA Setup**: [HTTPS & Custom CA Guide](https://gesellix.github.io/Bose-SoundTouch/guides/HTTPS-SETUP.html)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -389,6 +389,10 @@ func handleSpecialMessage(message *models.SpecialMessage, filters map[string]boo
|
||||
if !filters["userActivity"] {
|
||||
return
|
||||
}
|
||||
case models.MessageTypeUserInactivity:
|
||||
if !filters["userInactivity"] {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,6 +406,12 @@ func handleSpecialMessage(message *models.SpecialMessage, filters map[string]boo
|
||||
case models.MessageTypeUserActivity:
|
||||
fmt.Printf("\n👤 User Activity [%s]\n", message.DeviceID)
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" ⏰ Timestamp: %s\n", message.Timestamp.Format("15:04:05"))
|
||||
}
|
||||
case models.MessageTypeUserInactivity:
|
||||
fmt.Printf("\n💤 User Inactivity [%s]\n", message.DeviceID)
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" ⏰ Timestamp: %s\n", message.Timestamp.Format("15:04:05"))
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
@@ -35,23 +34,12 @@ func playTTS(c *cli.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// URL encode the text for Google TTS
|
||||
encodedText := url.QueryEscape(text)
|
||||
|
||||
// Build TTS URL with language support
|
||||
ttsURL := fmt.Sprintf("http://translate.google.com/translate_tts?ie=UTF-8&tl=%s&client=tw-ob&q=%s", language, encodedText)
|
||||
|
||||
// Create PlayInfo for TTS
|
||||
playInfo := &models.PlayInfo{
|
||||
URL: ttsURL,
|
||||
AppKey: appKey,
|
||||
Service: "TTS Notification",
|
||||
Message: "Google TTS",
|
||||
Reason: text,
|
||||
}
|
||||
|
||||
var playInfo *models.PlayInfo
|
||||
if volume > 0 {
|
||||
playInfo.SetVolume(volume)
|
||||
playInfo = models.NewTTSPlayInfo(text, appKey, language, volume)
|
||||
} else {
|
||||
playInfo = models.NewTTSPlayInfo(text, appKey, language)
|
||||
}
|
||||
|
||||
err = client.PlayCustom(playInfo)
|
||||
@@ -121,10 +109,11 @@ func playURL(c *cli.Context) error {
|
||||
}
|
||||
|
||||
// Create PlayInfo for URL content
|
||||
playInfo := models.NewURLPlayInfo(urlStr, appKey, service, message, reason)
|
||||
|
||||
var playInfo *models.PlayInfo
|
||||
if volume > 0 {
|
||||
playInfo.SetVolume(volume)
|
||||
playInfo = models.NewURLPlayInfo(urlStr, appKey, service, message, reason, volume)
|
||||
} else {
|
||||
playInfo = models.NewURLPlayInfo(urlStr, appKey, service, message, reason)
|
||||
}
|
||||
|
||||
err = client.PlayCustom(playInfo)
|
||||
|
||||
@@ -5,10 +5,10 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -17,11 +17,13 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/urfave/cli/v2"
|
||||
@@ -80,10 +82,15 @@ func main() {
|
||||
EnvVars: []string{"BIND_ADDR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "target-url",
|
||||
Usage: "URL for Python-based service components (legacy)",
|
||||
Name: "soundcork-url",
|
||||
Usage: "URL for Soundcork-based service components (legacy)",
|
||||
Value: "http://localhost:8001",
|
||||
EnvVars: []string{"PYTHON_BACKEND_URL", "TARGET_URL"},
|
||||
EnvVars: []string{"SOUNDCORK_BACKEND_URL", "TARGET_URL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "enable-soundcork-proxy",
|
||||
Usage: "Enable proxying unknown requests to the Soundcork backend",
|
||||
EnvVars: []string{"ENABLE_SOUNDCORK_PROXY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "data-dir",
|
||||
@@ -132,25 +139,157 @@ func main() {
|
||||
Value: "5m",
|
||||
EnvVars: []string{"DISCOVERY_INTERVAL"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "dns-discovery",
|
||||
Usage: "Enable DNS discovery server",
|
||||
EnvVars: []string{"ENABLE_DNS_DISCOVERY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dns-upstream",
|
||||
Usage: "Upstream DNS server for non-Bose queries",
|
||||
Value: "8.8.8.8",
|
||||
EnvVars: []string{"DNS_UPSTREAM"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dns-bind",
|
||||
Usage: "Bind address for the DNS discovery server",
|
||||
Value: ":53",
|
||||
EnvVars: []string{"DNS_BIND_ADDR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-client-id",
|
||||
Usage: "Spotify OAuth client ID",
|
||||
EnvVars: []string{"SPOTIFY_CLIENT_ID"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-client-secret",
|
||||
Usage: "Spotify OAuth client secret",
|
||||
EnvVars: []string{"SPOTIFY_CLIENT_SECRET"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-redirect-uri",
|
||||
Usage: "Spotify OAuth redirect URI",
|
||||
Value: "ueberboese-login://spotify",
|
||||
EnvVars: []string{"SPOTIFY_REDIRECT_URI"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mgmt-username",
|
||||
Usage: "Management API username for HTTP Basic Auth",
|
||||
Value: "admin",
|
||||
EnvVars: []string{"MGMT_USERNAME"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mgmt-password",
|
||||
Usage: "Management API password for HTTP Basic Auth",
|
||||
Value: "change_me!",
|
||||
EnvVars: []string{"MGMT_PASSWORD"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "base-url",
|
||||
Usage: "External base URL for OAuth callbacks behind reverse proxy",
|
||||
EnvVars: []string{"BASE_URL"},
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
config := loadConfig(c)
|
||||
ds := initDataStore(config.dataDir)
|
||||
|
||||
persisted := applyPersistedSettings(ds, &config)
|
||||
|
||||
if persisted.ServerURL == "" {
|
||||
log.Printf("Creating default settings.json in %s", config.dataDir)
|
||||
persisted = createDefaultSettings(ds, config)
|
||||
}
|
||||
|
||||
// Recalculate domains if settings changed
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname)
|
||||
|
||||
cm := initCertificateManager(config.dataDir)
|
||||
sm := setup.NewManager(config.serverURL, ds, cm)
|
||||
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record)
|
||||
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record, config.enableSoundcorkProxy)
|
||||
sm.GetDNSRunning = server.GetDNSRunning
|
||||
server.SetSoundcorkURL(config.soundcorkURL)
|
||||
server.SetHTTPServerURL(config.httpsServerURL)
|
||||
server.SetVersionInfo(version, commit, date)
|
||||
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
|
||||
server.SetDNSSettings(persisted.DNSEnabled, persisted.DNSUpstream, persisted.DNSBindAddr)
|
||||
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
|
||||
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
|
||||
server.SetBaseURL(config.baseURL)
|
||||
|
||||
if config.spotifyClientID != "" {
|
||||
spotifyService := spotify.NewSpotifyService(
|
||||
config.spotifyClientID,
|
||||
config.spotifyClientSecret,
|
||||
config.spotifyRedirectURI,
|
||||
config.dataDir,
|
||||
)
|
||||
server.SetSpotifyService(spotifyService)
|
||||
|
||||
clientIDPrefix := config.spotifyClientID
|
||||
if len(clientIDPrefix) > 8 {
|
||||
clientIDPrefix = clientIDPrefix[:8]
|
||||
}
|
||||
|
||||
log.Printf("Spotify service initialized (client ID: %s...)", clientIDPrefix)
|
||||
}
|
||||
|
||||
// Load and set initial DNS discoveries
|
||||
dnsDiscoveries, err := ds.LoadDNSDiscoveries()
|
||||
if err == nil && len(dnsDiscoveries) > 0 {
|
||||
initial := make(map[string]*discovery.DiscoveredHost)
|
||||
for _, entry := range dnsDiscoveries {
|
||||
initial[entry.Hostname] = &discovery.DiscoveredHost{
|
||||
Hostname: entry.Hostname,
|
||||
FirstSeen: entry.FirstSeen,
|
||||
LastSeen: entry.LastSeen,
|
||||
QueryCount: entry.QueryCount,
|
||||
IsBoseService: entry.IsBoseService,
|
||||
IsIntercepted: entry.IsIntercepted,
|
||||
RemoteAddr: entry.RemoteAddr,
|
||||
}
|
||||
}
|
||||
|
||||
server.SetDNSDiscoveries(initial)
|
||||
}
|
||||
|
||||
server.SetShortcuts(persisted.Shortcuts)
|
||||
|
||||
for path, status := range persisted.Shortcuts {
|
||||
log.Printf("Warning: configured shortcut: %s -> %d", path, status)
|
||||
}
|
||||
|
||||
recorder := proxy.NewRecorder(config.dataDir)
|
||||
recorder.Redact = config.redact
|
||||
patternsPath := filepath.Join(config.dataDir, "patterns.json")
|
||||
|
||||
patterns, err := proxy.LoadPatterns(patternsPath)
|
||||
if err == nil && len(patterns) > 0 {
|
||||
recorder.Patterns = patterns
|
||||
} else if err != nil {
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to load patterns from %s: %v", patternsPath, err)
|
||||
}
|
||||
|
||||
if len(patterns) == 0 {
|
||||
log.Printf("Creating default patterns at %s", patternsPath)
|
||||
|
||||
patterns = proxy.DefaultPatterns()
|
||||
|
||||
patternsData, jsonErr := json.MarshalIndent(patterns, "", " ")
|
||||
if jsonErr != nil {
|
||||
log.Printf("Warning: Failed to marshal default patterns: %v", jsonErr)
|
||||
} else {
|
||||
_ = os.WriteFile(patternsPath, patternsData, 0644)
|
||||
}
|
||||
}
|
||||
|
||||
if len(patterns) > 0 {
|
||||
recorder.Patterns = patterns
|
||||
}
|
||||
|
||||
server.SetRecorder(recorder)
|
||||
|
||||
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
|
||||
@@ -158,13 +297,11 @@ func main() {
|
||||
log.Printf("Warning: Failed to setup TLS: %v", err)
|
||||
}
|
||||
|
||||
pyProxy := setupPythonProxy(config.targetURL, config.redact, config.logBody, recorder, server)
|
||||
startDeviceDiscovery(server)
|
||||
|
||||
startDeviceDiscovery(server, config.discoveryInterval)
|
||||
r := setupRouter(server)
|
||||
|
||||
r := setupRouter(server, pyProxy)
|
||||
|
||||
log.Printf("Go service starting on %s, proxying to %s", config.serverURL, config.targetURL)
|
||||
log.Printf("Go service starting on %s, proxying to %s", config.serverURL, config.soundcorkURL)
|
||||
|
||||
if tlsConfig != nil {
|
||||
startHTTPSServer(config.httpsAddr, r, tlsConfig, config.httpsServerURL)
|
||||
@@ -198,19 +335,29 @@ func showVersionInfo(_ *cli.Context) error {
|
||||
}
|
||||
|
||||
type serviceConfig struct {
|
||||
port string
|
||||
bindAddr string
|
||||
addr string
|
||||
targetURL string
|
||||
dataDir string
|
||||
serverURL string
|
||||
httpsServerURL string
|
||||
httpsAddr string
|
||||
redact bool
|
||||
logBody bool
|
||||
record bool
|
||||
discoveryInterval time.Duration
|
||||
domains []string
|
||||
port string
|
||||
bindAddr string
|
||||
addr string
|
||||
soundcorkURL string
|
||||
dataDir string
|
||||
serverURL string
|
||||
httpsServerURL string
|
||||
httpsAddr string
|
||||
redact bool
|
||||
logBody bool
|
||||
record bool
|
||||
enableSoundcorkProxy bool
|
||||
dnsEnabled bool
|
||||
dnsUpstream string
|
||||
dnsBind string
|
||||
discoveryInterval time.Duration
|
||||
domains []string
|
||||
spotifyClientID string
|
||||
spotifyClientSecret string
|
||||
spotifyRedirectURI string
|
||||
mgmtUsername string
|
||||
mgmtPassword string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func loadConfig(c *cli.Context) serviceConfig {
|
||||
@@ -222,7 +369,7 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
addr = ":" + port
|
||||
}
|
||||
|
||||
targetURL := c.String("target-url")
|
||||
soundcorkURL := c.String("soundcork-url")
|
||||
dataDir := c.String("data-dir")
|
||||
|
||||
hostname, _ := os.Hostname()
|
||||
@@ -254,6 +401,11 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
redact := c.Bool("redact-logs")
|
||||
logBody := c.Bool("log-bodies")
|
||||
record := c.Bool("record-interactions")
|
||||
enableSoundcorkProxy := c.Bool("enable-soundcork-proxy")
|
||||
|
||||
dnsEnabled := c.Bool("dns-discovery")
|
||||
dnsUpstream := c.String("dns-upstream")
|
||||
dnsBind := c.String("dns-bind")
|
||||
|
||||
discoveryIntervalStr := c.String("discovery-interval")
|
||||
|
||||
@@ -264,20 +416,37 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
discoveryInterval = 5 * time.Minute
|
||||
}
|
||||
|
||||
spotifyClientID := c.String("spotify-client-id")
|
||||
spotifyClientSecret := c.String("spotify-client-secret")
|
||||
spotifyRedirectURI := c.String("spotify-redirect-uri")
|
||||
mgmtUsername := c.String("mgmt-username")
|
||||
mgmtPassword := c.String("mgmt-password")
|
||||
baseURL := c.String("base-url")
|
||||
|
||||
return serviceConfig{
|
||||
port: port,
|
||||
bindAddr: bindAddr,
|
||||
addr: addr,
|
||||
targetURL: targetURL,
|
||||
dataDir: dataDir,
|
||||
serverURL: serverURL,
|
||||
httpsServerURL: httpsServerURL,
|
||||
httpsAddr: httpsAddr,
|
||||
redact: redact,
|
||||
logBody: logBody,
|
||||
record: record,
|
||||
discoveryInterval: discoveryInterval,
|
||||
domains: domains,
|
||||
port: port,
|
||||
bindAddr: bindAddr,
|
||||
addr: addr,
|
||||
soundcorkURL: soundcorkURL,
|
||||
dataDir: dataDir,
|
||||
serverURL: serverURL,
|
||||
httpsServerURL: httpsServerURL,
|
||||
httpsAddr: httpsAddr,
|
||||
redact: redact,
|
||||
logBody: logBody,
|
||||
record: record,
|
||||
enableSoundcorkProxy: enableSoundcorkProxy,
|
||||
dnsEnabled: dnsEnabled,
|
||||
dnsUpstream: dnsUpstream,
|
||||
dnsBind: dnsBind,
|
||||
discoveryInterval: discoveryInterval,
|
||||
domains: domains,
|
||||
spotifyClientID: spotifyClientID,
|
||||
spotifyClientSecret: spotifyClientSecret,
|
||||
spotifyRedirectURI: spotifyRedirectURI,
|
||||
mgmtUsername: mgmtUsername,
|
||||
mgmtPassword: mgmtPassword,
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,6 +479,71 @@ func getDomains(serverURL, httpsServerURL, hostname string) []string {
|
||||
return domains
|
||||
}
|
||||
|
||||
func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) datastore.Settings {
|
||||
persisted, err := ds.GetSettings()
|
||||
if err != nil {
|
||||
return datastore.Settings{}
|
||||
}
|
||||
|
||||
if persisted.ServerURL != "" {
|
||||
config.serverURL = persisted.ServerURL
|
||||
}
|
||||
|
||||
if persisted.SoundcorkURL != "" {
|
||||
config.soundcorkURL = persisted.SoundcorkURL
|
||||
}
|
||||
|
||||
if persisted.HTTPServerURL != "" {
|
||||
config.httpsServerURL = persisted.HTTPServerURL
|
||||
}
|
||||
|
||||
if persisted.DiscoveryInterval != "" {
|
||||
if d, durErr := time.ParseDuration(persisted.DiscoveryInterval); durErr == nil {
|
||||
config.discoveryInterval = d
|
||||
}
|
||||
}
|
||||
|
||||
config.redact = persisted.RedactLogs
|
||||
config.logBody = persisted.LogBodies
|
||||
config.record = persisted.RecordInteractions
|
||||
config.enableSoundcorkProxy = persisted.EnableSoundcorkProxy
|
||||
|
||||
config.dnsEnabled = persisted.DNSEnabled
|
||||
if persisted.DNSUpstream != "" {
|
||||
config.dnsUpstream = persisted.DNSUpstream
|
||||
}
|
||||
|
||||
if persisted.DNSBindAddr != "" {
|
||||
config.dnsBind = persisted.DNSBindAddr
|
||||
}
|
||||
|
||||
return persisted
|
||||
}
|
||||
|
||||
func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datastore.Settings {
|
||||
settings := datastore.Settings{
|
||||
ServerURL: config.serverURL,
|
||||
SoundcorkURL: config.soundcorkURL,
|
||||
HTTPServerURL: config.httpsServerURL,
|
||||
RedactLogs: config.redact,
|
||||
LogBodies: config.logBody,
|
||||
RecordInteractions: config.record,
|
||||
DiscoveryInterval: config.discoveryInterval.String(),
|
||||
DiscoveryEnabled: true,
|
||||
EnableSoundcorkProxy: config.enableSoundcorkProxy,
|
||||
DNSEnabled: config.dnsEnabled,
|
||||
DNSUpstream: config.dnsUpstream,
|
||||
DNSBindAddr: config.dnsBind,
|
||||
Shortcuts: map[string]int{
|
||||
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
|
||||
"/sw.js": http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
_ = ds.SaveSettings(settings)
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
func initDataStore(dataDir string) *datastore.DataStore {
|
||||
ds := datastore.NewDataStore(dataDir)
|
||||
if err := ds.Initialize(); err != nil {
|
||||
@@ -328,55 +562,24 @@ func initCertificateManager(dataDir string) *certmanager.CertificateManager {
|
||||
return cm
|
||||
}
|
||||
|
||||
func setupPythonProxy(targetURL string, redact, logBody bool, recorder *proxy.Recorder, server *handlers.Server) *httputil.ReverseProxy {
|
||||
target, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse target URL: %v", err)
|
||||
}
|
||||
|
||||
pyProxy := httputil.NewSingleHostReverseProxy(target)
|
||||
pyProxy.ModifyResponse = func(res *http.Response) error {
|
||||
if etags, ok := res.Header["Etag"]; ok {
|
||||
delete(res.Header, "Etag")
|
||||
res.Header["ETag"] = etags
|
||||
}
|
||||
|
||||
currentLp := proxy.NewLoggingProxy(target.String(), redact)
|
||||
currentLp.LogBody = logBody
|
||||
currentLp.RecordEnabled = server.GetRecordEnabled()
|
||||
currentLp.SetRecorder(recorder)
|
||||
currentLp.LogResponse(res)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
originalPyDirector := pyProxy.Director
|
||||
pyProxy.Director = func(req *http.Request) {
|
||||
originalPyDirector(req)
|
||||
|
||||
currentLp := proxy.NewLoggingProxy(target.String(), redact)
|
||||
currentLp.LogBody = logBody
|
||||
currentLp.RecordEnabled = server.GetRecordEnabled()
|
||||
currentLp.SetRecorder(recorder)
|
||||
currentLp.LogRequest(req)
|
||||
}
|
||||
|
||||
return pyProxy
|
||||
}
|
||||
|
||||
func startDeviceDiscovery(server *handlers.Server, interval time.Duration) {
|
||||
func startDeviceDiscovery(server *handlers.Server) {
|
||||
go func() {
|
||||
for {
|
||||
server.DiscoverDevices(context.Background())
|
||||
time.Sleep(interval)
|
||||
currentInterval, enabled := server.GetDiscoverySettings()
|
||||
if enabled {
|
||||
server.DiscoverDevices(context.Background())
|
||||
}
|
||||
|
||||
time.Sleep(currentInterval)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.Mux {
|
||||
func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(server.OriginMiddleware)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(server.ShortcutMiddleware)
|
||||
r.Use(server.RecordMiddleware)
|
||||
|
||||
r.Get("/", server.HandleRoot)
|
||||
@@ -398,6 +601,13 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
|
||||
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
|
||||
})
|
||||
|
||||
// Legacy or direct domain calls without /bmx prefix
|
||||
r.Get("/registry/v1/services", server.HandleBMXRegistry)
|
||||
r.Get("/tunein/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
|
||||
r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
|
||||
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
|
||||
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
|
||||
|
||||
r.Route("/marge", func(r chi.Router) {
|
||||
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
|
||||
@@ -411,6 +621,37 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
|
||||
r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
|
||||
r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
})
|
||||
|
||||
// Legacy or direct domain calls without /marge prefix
|
||||
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Post("/streaming/support/power_on", server.HandleMargePowerOn)
|
||||
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
|
||||
r.Get("/accounts/{account}/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/accounts/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Post("/accounts/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
|
||||
r.Post("/accounts/{account}/devices", server.HandleMargeAddDevice)
|
||||
r.Delete("/accounts/{account}/devices/{device}", server.HandleMargeRemoveDevice)
|
||||
r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
|
||||
r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
|
||||
r.Route("/customer", func(r chi.Router) {
|
||||
r.Get("/account/{account}", server.HandleMargeAccountProfile)
|
||||
r.Post("/account/{account}", server.HandleMargeUpdateAccountProfile)
|
||||
r.Post("/account/{account}/password", server.HandleMargeChangePassword)
|
||||
})
|
||||
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Post("/stapp/{deviceId}", server.HandleAppEvents)
|
||||
r.Post("/scmudc/{deviceId}", server.HandleAppEvents)
|
||||
})
|
||||
|
||||
r.Route("/streaming/stats", func(r chi.Router) {
|
||||
@@ -418,35 +659,78 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
|
||||
r.Post("/error", server.HandleErrorStats)
|
||||
})
|
||||
|
||||
r.Route("/mgmt", func(r chi.Router) {
|
||||
// Browser OAuth callback — no auth required (Spotify redirects the
|
||||
// user's browser here directly). The authorization code is single-use,
|
||||
// short-lived, and useless without the client_secret.
|
||||
r.Get("/spotify/callback", server.HandleMgmtSpotifyCallback)
|
||||
|
||||
// All other management endpoints require Basic Auth.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.BasicAuthMgmt())
|
||||
r.Get("/accounts/{accountId}/speakers", server.HandleMgmtListSpeakers)
|
||||
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
|
||||
r.Post("/spotify/init", server.HandleMgmtSpotifyInit)
|
||||
r.Post("/spotify/confirm", server.HandleMgmtSpotifyConfirm)
|
||||
r.Get("/spotify/accounts", server.HandleMgmtSpotifyAccounts)
|
||||
r.Get("/spotify/token", server.HandleMgmtSpotifyToken)
|
||||
r.Post("/spotify/entity", server.HandleMgmtSpotifyEntity)
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/proxy/*", server.HandleProxyRequest)
|
||||
|
||||
r.Route("/devices", func(r chi.Router) {
|
||||
r.Get("/", server.HandleListDiscoveredDevices)
|
||||
r.Post("/", server.HandleAddManualDevice)
|
||||
|
||||
r.Route("/{deviceId}", func(r chi.Router) {
|
||||
r.Delete("/", server.HandleRemoveDevice)
|
||||
r.Get("/events", server.HandleGetDeviceEvents)
|
||||
r.Get("/info", server.HandleGetDeviceInfo)
|
||||
r.Get("/ws", server.HandleDeviceWebSocket)
|
||||
r.Post("/key/{key}", server.HandleDeviceKey)
|
||||
r.Post("/volume/{level}", server.HandleDeviceVolume)
|
||||
r.Post("/reboot", server.HandleRebootDevice)
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/version", server.HandleGetVersionInfo)
|
||||
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
r.Get("/devices", server.HandleListDiscoveredDevices)
|
||||
r.Post("/devices", server.HandleAddManualDevice)
|
||||
r.Post("/discover", server.HandleTriggerDiscovery)
|
||||
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
|
||||
r.Get("/settings", server.HandleGetSettings)
|
||||
r.Get("/info/{deviceIP}", server.HandleGetDeviceInfo)
|
||||
r.Get("/summary/{deviceIP}", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate/{deviceIP}", server.HandleMigrateDevice)
|
||||
r.Post("/revert/{deviceIP}", server.HandleRevertMigration)
|
||||
r.Post("/reboot/{deviceIP}", server.HandleRebootDevice)
|
||||
r.Post("/trust-ca/{deviceIP}", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services/{deviceIP}", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services/{deviceIP}", server.HandleRemoveRemoteServices)
|
||||
r.Post("/backup/{deviceIP}", server.HandleBackupConfig)
|
||||
r.Post("/sync/{deviceIP}", server.HandleInitialSync)
|
||||
r.Post("/test-connection/{deviceIP}", server.HandleTestConnection)
|
||||
r.Post("/test-hosts/{deviceIP}", server.HandleTestHostsRedirection)
|
||||
r.Post("/settings", server.HandleUpdateSettings)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
r.Get("/proxy-settings", server.HandleGetProxySettings)
|
||||
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
|
||||
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
|
||||
r.Get("/interaction-stats", server.HandleGetInteractionStats)
|
||||
r.Get("/interactions", server.HandleListInteractions)
|
||||
r.Get("/interaction-content", server.HandleGetInteractionContent)
|
||||
r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession)
|
||||
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
|
||||
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
|
||||
|
||||
r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
|
||||
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
|
||||
|
||||
r.Route("/devices/{deviceId}", func(r chi.Router) {
|
||||
r.Get("/summary", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate", server.HandleMigrateDevice)
|
||||
r.Post("/revert", server.HandleRevertMigration)
|
||||
r.Post("/trust-ca", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services", server.HandleRemoveRemoteServices)
|
||||
r.Post("/backup", server.HandleBackupConfig)
|
||||
r.Post("/sync", server.HandleInitialSync)
|
||||
r.Post("/test-connection", server.HandleTestConnection)
|
||||
r.Post("/test-hosts", server.HandleTestHostsRedirection)
|
||||
r.Post("/test-dns", server.HandleTestDNSRedirection)
|
||||
})
|
||||
})
|
||||
|
||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||
pyProxy.ServeHTTP(w, r)
|
||||
})
|
||||
r.NotFound(server.HandleNotFound)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func TestApplyPersistedSettings(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "main-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
|
||||
t.Run("overrides true with false", func(t *testing.T) {
|
||||
config := &serviceConfig{
|
||||
redact: true,
|
||||
logBody: true,
|
||||
record: true,
|
||||
enableSoundcorkProxy: true,
|
||||
}
|
||||
|
||||
// Simulate the bug by using the old bitwise OR logic in the test,
|
||||
// which should fail if we expect false.
|
||||
// config.redact = config.redact || false -> stays true
|
||||
|
||||
settings := datastore.Settings{
|
||||
RedactLogs: false,
|
||||
LogBodies: false,
|
||||
RecordInteractions: false,
|
||||
EnableSoundcorkProxy: false,
|
||||
}
|
||||
err := ds.SaveSettings(settings)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save settings: %v", err)
|
||||
}
|
||||
|
||||
applyPersistedSettings(ds, config)
|
||||
|
||||
if config.redact != false {
|
||||
t.Errorf("Expected redact to be false, got true")
|
||||
}
|
||||
if config.logBody != false {
|
||||
t.Errorf("Expected logBody to be false, got true")
|
||||
}
|
||||
if config.record != false {
|
||||
t.Errorf("Expected record to be false, got true")
|
||||
}
|
||||
if config.enableSoundcorkProxy != false {
|
||||
t.Errorf("Expected enableSoundcorkProxy to be false, got true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retains false when settings are false", func(t *testing.T) {
|
||||
settings := datastore.Settings{
|
||||
RedactLogs: false,
|
||||
}
|
||||
err := ds.SaveSettings(settings)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save settings: %v", err)
|
||||
}
|
||||
|
||||
config := &serviceConfig{
|
||||
redact: false,
|
||||
}
|
||||
|
||||
applyPersistedSettings(ds, config)
|
||||
|
||||
if config.redact != false {
|
||||
t.Errorf("Expected redact to be false, got true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("overrides false with true", func(t *testing.T) {
|
||||
settings := datastore.Settings{
|
||||
RedactLogs: true,
|
||||
}
|
||||
err := ds.SaveSettings(settings)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save settings: %v", err)
|
||||
}
|
||||
|
||||
config := &serviceConfig{
|
||||
redact: false,
|
||||
}
|
||||
|
||||
applyPersistedSettings(ds, config)
|
||||
|
||||
if config.redact != true {
|
||||
t.Errorf("Expected redact to be true, got false")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
accounts/
|
||||
certs/
|
||||
default/
|
||||
dns/
|
||||
interactions/
|
||||
patterns.json
|
||||
settings.json
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
[
|
||||
{
|
||||
"name": "IPv4",
|
||||
"regexp": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$",
|
||||
"replacement": "{ip}"
|
||||
},
|
||||
{
|
||||
"name": "DeviceID",
|
||||
"regexp": "^[A-F0-9]{12}$",
|
||||
"replacement": "{deviceId}"
|
||||
},
|
||||
{
|
||||
"name": "AccountID",
|
||||
"regexp": "^\\d{1,10}$",
|
||||
"replacement": "{accountId}"
|
||||
}
|
||||
]
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
This document contains important development guidelines for working on the Bose SoundTouch project. Please also read the following documentation:
|
||||
|
||||
- **[PLAN.md](PLAN.md)** - Project planning and roadmap
|
||||
- **[PLAN.md](archive/PLAN.md)** - Project planning and roadmap
|
||||
- **[PROJECT-PATTERNS.md](PROJECT-PATTERNS.md)** - Project structure and design patterns
|
||||
- **[API-Endpoints-Overview.md](API-Endpoints-Overview.md)** - API endpoints overview
|
||||
- **[API-ENDPOINTS.md](reference/API-ENDPOINTS.md)** - API endpoints overview
|
||||
- **[SoundTouch Web API.pdf](2025.12.18%20SoundTouch%20Web%20API.pdf)** - Official API documentation
|
||||
|
||||
## Development Guidelines
|
||||
@@ -95,4 +95,3 @@ When creating test data for API endpoints, prefer real device responses over hyp
|
||||
- **Documentation**: Completely in English for international accessibility
|
||||
- Conduct regular code reviews
|
||||
- Consider performance from the beginning
|
||||
|
||||
|
||||
@@ -195,8 +195,9 @@ soundtouch-cli --host 192.168.1.100 source internet-radio \
|
||||
- [SoundTouch WebServices API Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
|
||||
- [LOCAL_INTERNET_RADIO - streamUrl format](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API#select-local_internet_radio---streamurl-format)
|
||||
- [LOCAL_MUSIC](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API#select-local_music)
|
||||
- [Content Selection Example](/examples/content-selection/)
|
||||
- [CLI Reference](/docs/CLI-REFERENCE.md)
|
||||
- [Content Selection Example](../examples/content-selection/README.md)
|
||||
- [CLI Reference](guides/CLI-REFERENCE.md)
|
||||
- [Content Selection Example (Direct)](../examples/content-selection/)
|
||||
|
||||
## ✅ Verification
|
||||
|
||||
@@ -208,4 +209,4 @@ This implementation has been verified to:
|
||||
5. ✅ Include complete documentation and examples
|
||||
6. ✅ Maintain backward compatibility
|
||||
|
||||
**Status**: 🎉 **COMPLETE** - All requested content selection features are fully implemented and ready for use!
|
||||
**Status**: 🎉 **COMPLETE** - All requested content selection features are fully implemented and ready for use!
|
||||
|
||||
@@ -74,7 +74,7 @@ If you have a managed switch or a router capable of port mirroring, you can use
|
||||
### "IsItBose" Validation Failures
|
||||
If the device fails to connect to your custom service despite correct configuration, it may be failing the internal `IsItBose` regex check.
|
||||
- **Evidence**: Look for SSL handshake failures or "Unauthorized" errors in your service logs.
|
||||
- **Solution**: See the [Binary Patching section in DEVICE-REDIRECT-METHODS.md](DEVICE-REDIRECT-METHODS.md#method-3-binary-patching).
|
||||
- **Solution**: See the [Binary Patching section in DEVICE-REDIRECT-METHODS.md](analysis/DEVICE-REDIRECT-METHODS.md#method-3-binary-patching).
|
||||
|
||||
### Disappearing Sources (TuneIn/Local Radio)
|
||||
If `TUNEIN` or `LOCAL_INTERNET_RADIO` sources disappear after a reboot in an offline environment.
|
||||
|
||||
@@ -895,4 +895,4 @@ For additional help:
|
||||
|
||||
---
|
||||
|
||||
*This guide covers the complete navigation and station management functionality. For preset management, see [PRESET-MANAGEMENT.md](PRESET-MANAGEMENT.md).*
|
||||
*This guide covers the complete navigation and station management functionality. For preset management, see [PRESET-MANAGEMENT.md](reference/PRESET-MANAGEMENT.md).*
|
||||
|
||||
@@ -332,14 +332,14 @@ soundtouch-cli --host 192.168.1.100 info
|
||||
|
||||
## Next Steps
|
||||
|
||||
- 📖 [Complete CLI Reference](CLI-REFERENCE.md)
|
||||
- 🔧 [Full Implementation Guide](preset-store.md)
|
||||
- 📡 [WebSocket Events Documentation](websocket-events.md)
|
||||
- 📖 [Complete CLI Reference](guides/CLI-REFERENCE.md)
|
||||
- 🔧 [Full Implementation Guide](reference/PRESET-MANAGEMENT.md)
|
||||
- 📡 [WebSocket Events Documentation](reference/WEBSOCKET-EVENTS.md)
|
||||
- 💻 [Preset Management Example](../examples/preset-management/)
|
||||
- 📚 [API Endpoints Overview](API-Endpoints-Overview.md)
|
||||
- 📚 [API Endpoints Overview](reference/API-ENDPOINTS.md)
|
||||
|
||||
## Need Help?
|
||||
|
||||
- 🐛 **Bug Reports**: [Create an issue](https://github.com/gesellix/bose-soundtouch/issues)
|
||||
- 💡 **Feature Requests**: [Start a discussion](https://github.com/gesellix/bose-soundtouch/discussions)
|
||||
- ❓ **Questions**: [Browse discussions](https://github.com/gesellix/bose-soundtouch/discussions)
|
||||
- ❓ **Questions**: [Browse discussions](https://github.com/gesellix/bose-soundtouch/discussions)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Bose SoundTouch Toolkit Documentation
|
||||
|
||||
Welcome to the documentation for the Bose SoundTouch Toolkit. This toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026.
|
||||
|
||||
## 📖 Quick Links
|
||||
|
||||
- [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
|
||||
- [Migration & Safety Guide](guides/MIGRATION-SAFETY.md)
|
||||
- [CLI Reference](guides/CLI-REFERENCE.md)
|
||||
- [Getting Started](guides/GETTING-STARTED.md)
|
||||
- [SoundTouch Service Guide](guides/SOUNDTOUCH-SERVICE.md)
|
||||
|
||||
## 🗂 Documentation Structure
|
||||
|
||||
### User Guides
|
||||
- [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md)
|
||||
- [HTTPS Setup](guides/HTTPS-SETUP.md)
|
||||
- [Deployment Guide](guides/DEPLOYMENT.md)
|
||||
- [Raspberry Pi Setup](guides/RASPBERRY-PI.md)
|
||||
- [Troubleshooting](guides/TROUBLESHOOTING.md)
|
||||
|
||||
### Technical Reference
|
||||
- [API Endpoints](reference/API-ENDPOINTS.md)
|
||||
- [WebSocket Events](reference/WEBSOCKET-EVENTS.md)
|
||||
- [Zone Management](reference/ZONE-MANAGEMENT.md)
|
||||
- [Preset Management](reference/PRESET-MANAGEMENT.md)
|
||||
|
||||
### Analysis & Research
|
||||
- [Upstream URLs](analysis/UPSTREAM-URLS.md)
|
||||
- [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
|
||||
|
||||
For a complete list of all documents, see the [Summary](SUMMARY.md).
|
||||
@@ -23,7 +23,7 @@ This document summarizes the implementation of the `/serviceAvailability` endpoi
|
||||
### Modified Files
|
||||
|
||||
1. **`pkg/client/client.go`** - Added `GetServiceAvailability()` method
|
||||
2. **`docs/API-Endpoints-Overview.md`** - Updated implementation status
|
||||
2. **`docs/reference/API-ENDPOINTS.md`** - Updated implementation status
|
||||
3. **`docs/UNIMPLEMENTED-ENDPOINTS.md`** - Marked as implemented
|
||||
|
||||
## API Interface
|
||||
@@ -263,4 +263,4 @@ BenchmarkGetServiceAvailability-8 1000 1.2ms/op
|
||||
✅ **Performance benchmarks established**
|
||||
✅ **Error handling verified**
|
||||
|
||||
The ServiceAvailability implementation is production-ready and provides a solid foundation for building user-friendly SoundTouch applications with better service discovery and user feedback capabilities.
|
||||
The ServiceAvailability implementation is production-ready and provides a solid foundation for building user-friendly SoundTouch applications with better service discovery and user feedback capabilities.
|
||||
|
||||
@@ -119,7 +119,7 @@ soundtouch-service
|
||||
```go
|
||||
// Build custom applications on top of local services
|
||||
client := &http.Client{}
|
||||
resp, _ := client.Get("http://localhost:8000/setup/devices")
|
||||
resp, _ := client.Get("http://localhost:8000/devices")
|
||||
```
|
||||
|
||||
### Privacy-Conscious Users
|
||||
@@ -144,10 +144,10 @@ LOG_PROXY_BODY=true soundtouch-service
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **[Complete Service Guide](SOUNDTOUCH-SERVICE.md)**: Comprehensive setup and configuration
|
||||
- **[API Reference](SOUNDTOUCH-SERVICE.md#api-reference)**: Full endpoint documentation
|
||||
- **[Migration Guide](SOUNDTOUCH-SERVICE.md#device-migration)**: Step-by-step device migration
|
||||
- **[Troubleshooting](SOUNDTOUCH-SERVICE.md#troubleshooting)**: Common issues and solutions
|
||||
- **[Complete Service Guide](guides/SOUNDTOUCH-SERVICE.md)**: Comprehensive setup and configuration
|
||||
- **[API Reference](guides/SOUNDTOUCH-SERVICE.md#api-reference)**: Full endpoint documentation
|
||||
- **[Migration Guide](guides/SOUNDTOUCH-SERVICE.md#device-migration)**: Step-by-step device migration
|
||||
- **[Troubleshooting](guides/SOUNDTOUCH-SERVICE.md#troubleshooting)**: Common issues and solutions
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
@@ -172,9 +172,9 @@ The collaborative spirit of reverse engineering and documentation in the SoundTo
|
||||
## 🔗 Links
|
||||
|
||||
- **[Main Repository](https://github.com/gesellix/bose-soundtouch)**
|
||||
- **[Service Documentation](SOUNDTOUCH-SERVICE.md)**
|
||||
- **[CLI Documentation](CLI-REFERENCE.md)**
|
||||
- **[Getting Started Guide](GETTING-STARTED.md)**
|
||||
- **[Service Documentation](guides/SOUNDTOUCH-SERVICE.md)**
|
||||
- **[CLI Documentation](guides/CLI-REFERENCE.md)**
|
||||
- **[Getting Started Guide](guides/GETTING-STARTED.md)**
|
||||
- **[SoundCork Project](https://github.com/deborahgu/soundcork)**
|
||||
- **[ÜberBöse API](https://github.com/julius-d/ueberboese-api)**
|
||||
|
||||
@@ -187,4 +187,4 @@ go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
|
||||
soundtouch-service
|
||||
```
|
||||
|
||||
Open `http://localhost:8000` and start your journey to local SoundTouch control! 🎵
|
||||
Open `http://localhost:8000` and start your journey to local SoundTouch control! 🎵
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Table of Contents
|
||||
|
||||
* [Introduction](README.md)
|
||||
|
||||
## User Guides
|
||||
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
|
||||
* [Migration & Safety Guide](guides/MIGRATION-SAFETY.md)
|
||||
@@ -9,11 +11,20 @@
|
||||
* [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md)
|
||||
* [HTTPS Setup](guides/HTTPS-SETUP.md)
|
||||
* [Deployment](guides/DEPLOYMENT.md)
|
||||
* [Raspberry Pi Guide](guides/RASPBERRY-PI.md)
|
||||
* [Troubleshooting](guides/TROUBLESHOOTING.md)
|
||||
* [Useful Links](#useful-links)
|
||||
|
||||
### Useful Links
|
||||
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
|
||||
* [Raspberry Pi Installer](../scripts/raspberry-pi/README.md)
|
||||
* [Updating the Service](../scripts/raspberry-pi/README.md#updating-to-a-new-version)
|
||||
* [CLI Reference](guides/CLI-REFERENCE.md)
|
||||
|
||||
## Technical Reference
|
||||
* [API Cookbook](reference/API-COOKBOOK.md)
|
||||
* [API Endpoints](reference/API-ENDPOINTS.md)
|
||||
* [Cloud API Emulation](reference/CLOUD-API.md)
|
||||
* [System Endpoints](reference/SYSTEM-ENDPOINTS.md)
|
||||
* [Speaker Endpoint](reference/SPEAKER-ENDPOINT.md)
|
||||
* [WebSocket Events](reference/WEBSOCKET-EVENTS.md)
|
||||
@@ -22,6 +33,7 @@
|
||||
* [Preset Management](reference/PRESET-MANAGEMENT.md)
|
||||
* [Source Selection](reference/SOURCE-SELECTION.md)
|
||||
* [Volume Controls](reference/VOLUME-CONTROLS.md)
|
||||
* [RadioBrowser](reference/radio-browser.md)
|
||||
* [Bass Controls](reference/BASS-CONTROLS.md)
|
||||
* [Key Controls](reference/KEY-CONTROLS.md)
|
||||
* [Feature Mapping](reference/FEATURE-MAPPING.md)
|
||||
@@ -32,6 +44,7 @@
|
||||
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
|
||||
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
|
||||
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
|
||||
* [Stockholm App Analysis](analysis/stockholm-app-analysis.md)
|
||||
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
|
||||
|
||||
## Appendix (Other Documents)
|
||||
|
||||
@@ -370,7 +370,7 @@ soundtouch-cli speaker beep
|
||||
**Go Client Usage:**
|
||||
```go
|
||||
// Text-to-Speech
|
||||
client.PlayTTS("Hello World", "your-app-key", 70)
|
||||
client.PlayTTS("Hello World", "your-app-key", "EN", 70)
|
||||
|
||||
// URL content
|
||||
client.PlayURL("https://example.com/audio.mp3", "your-app-key", "Service", "Message", "Reason", 60)
|
||||
@@ -1044,4 +1044,4 @@ The SoundTouch Plus Wiki provides comprehensive documentation for **64 additiona
|
||||
|
||||
This documentation provides the complete foundation for implementing all endpoints from the SoundTouch Plus Wiki, enabling this Go library to become the definitive SoundTouch integration solution for everything from basic home automation to professional audio installations.
|
||||
|
||||
*All examples and XML structures are verified against real SoundTouch hardware and extensively tested by the SoundTouch Plus community.*
|
||||
*All examples and XML structures are verified against real SoundTouch hardware and extensively tested by the SoundTouch Plus community.*
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
title: Bose SoundTouch Toolkit
|
||||
description: Documentation for controlling and preserving Bose SoundTouch devices
|
||||
remote_theme: pages-themes/minimal@v0.2.0
|
||||
plugins:
|
||||
- jekyll-remote-theme
|
||||
- jekyll-relative-links
|
||||
relative_links:
|
||||
enabled: true
|
||||
collections: true
|
||||
include:
|
||||
- SUMMARY.md
|
||||
@@ -9,6 +9,9 @@ SoundTouch devices primarily communicate with the following domains:
|
||||
- `updates.bose.com`: Software updates
|
||||
- `stats.bose.com`: Telemetry and analytics
|
||||
- `bmx.bose.com`: Bose Media eXchange registry
|
||||
- `events.api.bosecm.com`: Stockholm app analytics
|
||||
- `bose-prod.apigee.net`: Apigee gateway (used by some services)
|
||||
- `worldwide.bose.com`: Software update metadata and secondary services
|
||||
|
||||
---
|
||||
|
||||
@@ -153,7 +156,7 @@ For developers creating a completely isolated "dark" environment (no internet at
|
||||
1. **XML**: Point all URLs to local services.
|
||||
2. **Binary Patch**: Neutralize `IsItBose` to allow non-Bose domains/IPs.
|
||||
3. **`/etc/hosts`**: Redirect hardcoded domains that aren't exposed in the XML (like analytics or NTP) to prevent leakage to the real Bose cloud.
|
||||
4. **Process Instrumentation**: Use [SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook) to monitor and override internal behavior in real-time.
|
||||
4. **Process Instrumentation**: Use [SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook) to monitor and override internal behavior in real-time. This is particularly useful for handling unknown hostnames or deep-hooking into service discovery logic that might bypass standard DNS lookups.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
### Stockholm App Analysis Report
|
||||
|
||||
#### 1. Overview
|
||||
The Stockholm app is a CEPE MAUI SoundTouch Controller HTML5/JS UI. It is designed to run as a web-based interface for Bose SoundTouch devices, likely served by the device itself or an associated controller.
|
||||
|
||||
- **Technology Stack**: HTML5, CSS3, JavaScript (Minified).
|
||||
- **Key Libraries**:
|
||||
- **jQuery**: Core DOM manipulation and event handling.
|
||||
- **iScroll**: Used for smooth scrolling in lists and carousels.
|
||||
- **Forge**: Used for cryptographic operations (likely for secure communication or authentication).
|
||||
- **WebSocket Polyfill**: Ensures WebSocket compatibility across environments.
|
||||
|
||||
#### 2. Directory Structure
|
||||
- `js/`: Core application logic.
|
||||
- `app/`: Main application entry point (`app.js`).
|
||||
- `models/`: Data models for UI components (Presets, Favorites, Onboarding, etc.).
|
||||
- `music_services/`: Implementation of various music services (Amazon, Deezer, Spotify, BMX, etc.).
|
||||
- `views/`: UI view templates and logic.
|
||||
- `utils/`: Utility functions for security, data analytics, and general-purpose tasks.
|
||||
- `json/`: Configuration files and static data.
|
||||
- `config.json`: Core application configuration including Base64 encoded Bose API endpoints (e.g., streaming, events, BMX registry).
|
||||
- `sourceFeatures.json`: Capability mapping for different sources.
|
||||
- `setup/`: Onboarding and initial device setup logic.
|
||||
- `lang/`: Localization files for multi-language support.
|
||||
|
||||
#### 3. Communication Architecture
|
||||
The app uses several communication channels to interact with the SoundTouch ecosystem:
|
||||
|
||||
- **Socket Communication (`socket_comm.js`)**: Real-time updates and low-latency commands via WebSockets.
|
||||
- **BMX (`bmx.js` & `js/music_services/bmx/`)**: Interactions with the Bose Music eXperience services. Handles account management, navigation, and API response validation.
|
||||
- **Marge (`marge_comm.js`)**: Likely used for interaction with the Marge service (Bose's legacy cloud/proxy service).
|
||||
- **Worker-based Architecture**: Many services use Web Workers (`bmx_worker.js`, `spotify_worker.js`) to handle API requests and data processing in the background, keeping the UI responsive.
|
||||
|
||||
#### 4. Key Features & Functionality
|
||||
- **Multi-Device Management**: Discovering and controlling multiple speakers on the network.
|
||||
- **Music Service Integration**: Deep integration with Spotify, Amazon Music, Deezer, and Pandora.
|
||||
- **Preset Management**: Browsing and setting presets directly from the UI.
|
||||
- **Zone Control**: Creating and managing multi-room groups (Master/Slave configurations).
|
||||
- **Onboarding**: A dedicated setup flow for new devices.
|
||||
- **Analytics & Data Collection**: Modules like `data_analytics.js` and `dc_server.js` suggest tracking of user interactions.
|
||||
|
||||
#### 5. Integration Opportunities for Bose-SoundTouch Project
|
||||
Based on the Stockholm app's capabilities, the following features could be enhanced or added to our Go-based `soundtouch-service`:
|
||||
|
||||
1. **Enhanced BMX Emulation**: Use insights from `bmx_client.js` and `bmx_navigate_response_generator.js` to improve our local BMX implementation.
|
||||
2. **Spotify/Amazon Service Proxies**: Implement the backend logic required to support the same API calls the Stockholm app makes to these services.
|
||||
3. **UI parity**: The Stockholm app's view templates (`views/`) can serve as a reference for our Web Management UI.
|
||||
4. **WebSocket Support**: Ensure our service provides a robust WebSocket interface similar to what the Stockholm app expects for real-time state synchronization.
|
||||
5. **Capability Discovery**: Better utilization of the `sourceFeatures.json` logic to dynamically show/hide features based on the device model and firmware version.
|
||||
|
||||
#### 6. Conclusion
|
||||
The Stockholm app is a mature, full-featured controller that relies heavily on Bose's proprietary BMX and Marge services. By analyzing its client-side logic, we can better understand the expected API responses and interaction patterns needed to provide a seamless local replacement for the Bose Cloud.
|
||||
@@ -784,4 +784,4 @@ docker-compose up # Mock devices + web app
|
||||
- [UPnP Device Architecture](http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.0.pdf)
|
||||
- [Go Embed Directive](https://pkg.go.dev/embed)
|
||||
- [Gorilla WebSocket](https://github.com/gorilla/websocket)
|
||||
- [PROJECT-PATTERNS.md](./PROJECT-PATTERNS.md) - Detailed pattern documentation
|
||||
- [PROJECT-PATTERNS.md](../PROJECT-PATTERNS.md) - Detailed pattern documentation
|
||||
|
||||
@@ -206,14 +206,14 @@ This project implements a comprehensive Go client library and CLI tool for Bose
|
||||
|
||||
### ✅ Complete Documentation
|
||||
- `README.md` - Project overview and usage examples ✅
|
||||
- `docs/API-Endpoints-Overview.md` - API reference with status ✅
|
||||
- `docs/KEY-CONTROLS.md` - Media control implementation ✅
|
||||
- `docs/VOLUME-CONTROLS.md` - Volume management guide ✅
|
||||
- `docs/PRESET-MANAGEMENT.md` - Preset analysis and limitations ✅
|
||||
- `docs/reference/API-ENDPOINTS.md` - API reference with status ✅
|
||||
- `docs/reference/KEY-CONTROLS.md` - Media control implementation ✅
|
||||
- `docs/guides/VOLUME-CONTROLS.md` - Volume management guide ✅
|
||||
- `docs/reference/PRESET-MANAGEMENT.md` - Preset analysis and limitations ✅
|
||||
- `docs/HOST-PORT-PARSING.md` - Enhanced CLI feature ✅
|
||||
- `docs/PLAN.md` - Development roadmap (updated) ✅
|
||||
- `docs/archive/PLAN.md` - Development roadmap (updated) ✅
|
||||
- `docs/PROJECT-PATTERNS.md` - Development guidelines ✅
|
||||
- `SPEAKER_ENDPOINT.md` - Complete speaker notification documentation ✅
|
||||
- `docs/reference/SPEAKER-ENDPOINT.md` - Complete speaker notification documentation ✅
|
||||
|
||||
### 📝 Documentation Notes
|
||||
- All docs are synchronized with current implementation
|
||||
|
||||
@@ -1248,6 +1248,6 @@ SOUNDTOUCH_DISCOVERY_TIMEOUT=10s
|
||||
## See Also
|
||||
|
||||
- [Getting Started Guide](GETTING-STARTED.md) - Basic setup and usage
|
||||
- [WebSocket Events](websocket-events.md) - Real-time monitoring
|
||||
- [Zone Management](zone-management.md) - Multi-room setup
|
||||
- [API Endpoints](API-Endpoints-Overview.md) - Complete API reference
|
||||
- [WebSocket Events](../reference/WEBSOCKET-EVENTS.md) - Real-time monitoring
|
||||
- [Zone Management](../reference/ZONE-MANAGEMENT.md) - Multi-room setup
|
||||
- [API Endpoints](../reference/API-ENDPOINTS.md) - Complete API reference
|
||||
|
||||
@@ -13,6 +13,10 @@ This guide covers everything you need to know to deploy robust, scalable SoundTo
|
||||
- [Performance Optimization](#performance-optimization)
|
||||
- [Error Handling Recovery](#error-handling-recovery)
|
||||
- [Deployment Strategies](#deployment-strategies)
|
||||
- [Docker Deployment](#docker-deployment)
|
||||
- [Kubernetes Deployment](#kubernetes-deployment)
|
||||
- [Systemd Service](#systemd-service)
|
||||
- [Raspberry Pi Installer](#raspberry-pi-installer)
|
||||
- [Maintenance Operations](#maintenance-operations)
|
||||
|
||||
---
|
||||
@@ -926,39 +930,49 @@ data:
|
||||
device_hosts: "192.168.1.100,192.168.1.101,192.168.1.102"
|
||||
```
|
||||
|
||||
### Systemd Service
|
||||
#### Systemd Service
|
||||
|
||||
A standard systemd unit for manual installation. This example assumes the binary is at `/usr/local/bin/soundtouch-service` and data is stored in `/var/lib/soundtouch-service`.
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/soundtouch.service
|
||||
# /etc/systemd/system/soundtouch-service.service
|
||||
[Unit]
|
||||
Description=SoundTouch Control Service
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
Description=Bose SoundTouch Service
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=soundtouch
|
||||
Group=soundtouch
|
||||
WorkingDirectory=/opt/soundtouch
|
||||
ExecStart=/opt/soundtouch/bin/soundtouch-app
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=DEVICE_HOSTS=192.168.1.100,192.168.1.101
|
||||
Environment=LOG_LEVEL=info
|
||||
Environment=CONFIG_FILE=/opt/soundtouch/config/production.yaml
|
||||
WorkingDirectory=/var/lib/soundtouch-service
|
||||
ExecStart=/usr/local/bin/soundtouch-service
|
||||
Environment=PORT=80
|
||||
Environment=SERVER_URL=http://soundtouch.local
|
||||
|
||||
# Security settings
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/soundtouch/logs
|
||||
# Allow binding to privileged ports (80/443) without running as root
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Security hardening
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/soundtouch-service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
#### Raspberry Pi Installer
|
||||
|
||||
For users deploying on a Raspberry Pi, we provide a specialized automated installer that handles everything from architecture detection to security hardening.
|
||||
|
||||
See the [Raspberry Pi Installation Guide](RASPBERRY-PI.md) for step-by-step instructions.
|
||||
|
||||
---
|
||||
|
||||
## Maintenance Operations
|
||||
@@ -1071,4 +1085,4 @@ func init() {
|
||||
|
||||
// Set GC target percentage
|
||||
if os.Getenv("GOGC") == "" {
|
||||
debug.SetGCPerc
|
||||
debug.SetGCPerc
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# HTTPS Setup & Custom CA Certificate
|
||||
|
||||
To use the `/etc/hosts` redirection method safely, SoundTouch devices must communicate over HTTPS. This requires the device to trust the Root CA certificate used by the local `soundtouch-service`.
|
||||
To use the `/etc/hosts` redirection method safely, SoundTouch devices must communicate over HTTPS. This requires the device to trust the AfterTouch Root CA certificate used by the local service.
|
||||
|
||||
## 1. Automated Migration (Hosts Method)
|
||||
|
||||
@@ -13,12 +13,12 @@ curl -X POST "http://localhost:8000/setup/migrate/{deviceIP}?method=hosts"
|
||||
This command will:
|
||||
1. Connect to the device via SSH.
|
||||
2. Update `/etc/hosts` to point Bose domains to the service IP.
|
||||
3. Inject the auto-generated Root CA into the device's trust store (`/etc/pki/tls/certs/ca-bundle.crt`).
|
||||
3. Inject the auto-generated AfterTouch Root CA into the device's trust store (`/etc/pki/tls/certs/ca-bundle.crt`).
|
||||
4. Reboot the device.
|
||||
|
||||
## 2. Managing the Root CA
|
||||
|
||||
The `soundtouch-service` automatically generates a Root CA when it first starts.
|
||||
The AfterTouch service automatically generates a Root CA when it first starts.
|
||||
|
||||
- **CA Certificate**: `data/certs/ca.crt`
|
||||
- **CA Private Key**: `data/certs/ca.key`
|
||||
@@ -34,7 +34,7 @@ The `soundtouch-service` now includes a built-in HTTPS listener. This simplifies
|
||||
- **HTTPS Port**: Configurable via `HTTPS_PORT` environment variable (defaults to `8443`).
|
||||
- **HTTPS Server URL**: Configurable via `HTTPS_SERVER_URL` (e.g., `https://mysoundtouch.local:8443`). If not set, the service attempts to guess it using the system hostname.
|
||||
- **Domain Coverage**: Automatically presents a certificate for `streaming.bose.com`, `updates.bose.com`, `stats.bose.com`, `bmx.bose.com`, and `content.api.bose.io`.
|
||||
- **Automatic Setup**: On first start, it generates a server certificate signed by your local Root CA.
|
||||
- **Automatic Setup**: On first start, it generates a server certificate signed by your AfterTouch local Root CA.
|
||||
|
||||
#### TLS Security
|
||||
|
||||
|
||||
@@ -27,7 +27,10 @@ Before you proceed with the actual migration, follow these steps:
|
||||
4. **Validate SSH Access**: Confirm the device responds to SSH without a password.
|
||||
- In the Web UI **Migration** tab, select your speaker and verify that the "SSH Connection" status shows ✅ Success.
|
||||
- This toolkit automatically handles the necessary SSH parameters (ciphers and key exchanges) required by older Bose firmware.
|
||||
5. **Use XML Migration First**: The `XML` migration method is less invasive than the `Hosts` method. It only changes the application config and doesn't require modifying the system's DNS/CA trust store if you don't need full HTTPS interception initially.
|
||||
5. **Migration Methods**:
|
||||
- **XML Migration (Default)**: Less invasive, only changes the application config. Best for simple redirection.
|
||||
- **Hosts Migration**: Modifies `/etc/hosts` on the device. Good for system-wide redirection of specific domains.
|
||||
- **ResolvConf Migration**: Points the device to the AfterTouch DNS server. Best for discovering unknown Bose endpoints and dynamic interception. **Note**: This method requires the DNS Discovery Server to be running on port 53. The service includes a pre-flight check to ensure the server is properly bound before allowing this migration.
|
||||
6. **Monitor Logs**: Run the `soundtouch-service` with `DEBUG` or `INFO` logging to see the step-by-step progress of the migration.
|
||||
|
||||
#### 🔄 Rollback Strategy
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Raspberry Pi Installation Guide
|
||||
|
||||
This guide explains how to install the `soundtouch-service` as a persistent systemd service on a Raspberry Pi (tested on Raspberry Pi Zero 2W, 3, and 4).
|
||||
|
||||
## Automated Installer
|
||||
|
||||
We provide a specialized installer script located in the `scripts/raspberry-pi/` directory of the repository.
|
||||
|
||||
### Features
|
||||
* **Automatic start on boot**: Installs a systemd unit.
|
||||
* **Non-root operation**: Uses `AmbientCapabilities` to bind to ports 80/443 without root privileges.
|
||||
* **Arch Detection**: Automatically selects the correct binary for `armv7`, `arm64`, or `amd64`.
|
||||
* **Easy Updates**: Re-running the script updates the binary to the latest version.
|
||||
|
||||
### Installation Steps
|
||||
|
||||
1. **Download the installer**:
|
||||
```bash
|
||||
curl -fsSL -o install.sh https://raw.githubusercontent.com/gesellix/bose-soundtouch/main/scripts/raspberry-pi/install.sh
|
||||
```
|
||||
|
||||
2. **Run with sudo**:
|
||||
```bash
|
||||
sudo bash install.sh
|
||||
```
|
||||
|
||||
### Overriding Defaults
|
||||
|
||||
You can customize the installation using environment variables:
|
||||
|
||||
```bash
|
||||
sudo \
|
||||
VERSION=v0.17.0 \
|
||||
HOSTNAME_FQDN=soundtouch.local \
|
||||
HTTP_PORT=80 \
|
||||
HTTPS_PORT=443 \
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
### Updating the Service
|
||||
|
||||
To update the service to a specific version, run the installer with the version as an argument:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh v0.18.1
|
||||
```
|
||||
|
||||
The installer will automatically fetch the latest version of itself for that release and then update the service binary and restart it.
|
||||
|
||||
## Management
|
||||
|
||||
Once installed, use standard `systemctl` commands to manage the service:
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
systemctl status soundtouch-service
|
||||
|
||||
# Follow logs
|
||||
journalctl -u soundtouch-service -f
|
||||
|
||||
# Restart
|
||||
sudo systemctl restart soundtouch-service
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is stored in `/etc/soundtouch-service/soundtouch-service.env`. Note that settings saved via the Web UI (in `settings.json`) will take precedence over these environment variables once the service is running.
|
||||
|
||||
For more details, see the [scripts/raspberry-pi/README.md](../../scripts/raspberry-pi/README.md) in the repository.
|
||||
@@ -7,13 +7,16 @@ The `soundtouch-service` is a comprehensive local server that emulates Bose's cl
|
||||
The service provides:
|
||||
|
||||
- **🏠 Local Service Emulation**: Complete BMX (Bose Media eXchange) and Marge service implementation
|
||||
- **🔧 Device Migration**: Seamlessly migrate devices from Bose cloud to local services
|
||||
- **🔧 Device Migration**: Seamlessly migrate devices from Bose cloud to local services via XML config, `/etc/hosts`, or `/etc/resolv.conf`
|
||||
- **🔍 DNS Discovery & Interception**: Built-in DNS server to discover unknown Bose endpoints and selectively intercept cloud traffic
|
||||
- **📊 Traffic Proxying**: Inspect and log all device communications for debugging
|
||||
- **🌐 Web Management UI**: Browser-based interface for device management
|
||||
- **💾 Persistent Data**: Store device configurations, presets, and usage statistics
|
||||
- **📝 HTTP Recording**: Persist all interactions as re-playable `.http` files
|
||||
- **📥 Session Archiving**: Download entire interaction sessions as `.tar.gz` for offline analysis
|
||||
- **🔍 Auto-Discovery**: Automatically detect and configure SoundTouch devices
|
||||
- **🔒 Offline Operation**: Continue using full device functionality without internet
|
||||
- **🔗 Bose Proxy & Soundcork Fallback**: Dynamic proxying with automatic fallback to local [SoundCork](https://github.com/deborahgu/soundcork) emulation if enabled
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -136,21 +139,35 @@ Use the web interface or API to migrate devices from Bose cloud services to your
|
||||
|
||||
## Configuration
|
||||
|
||||
The service can be configured via environment variables or command-line flags:
|
||||
### Configuration Precedence
|
||||
|
||||
| Variable | Flag | Description | Default |
|
||||
|------------------------------------|----------------------------|--------------------------------------------------|---------------------------|
|
||||
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
|
||||
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
|
||||
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
|
||||
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
|
||||
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
|
||||
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>:8443` |
|
||||
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
|
||||
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
|
||||
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
|
||||
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
|
||||
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
|
||||
The service supports multiple ways to configure its behavior. When multiple sources provide the same setting, the following precedence rules apply (highest to lowest):
|
||||
|
||||
1. **`settings.json`**: Settings saved via the Web UI (stored in the data directory) take the highest precedence. This ensures that changes made in the browser persist across service restarts even if environment variables or flags change.
|
||||
2. **Environment Variables / CLI Flags**: If a setting is not present in `settings.json`, environment variables and flags are used.
|
||||
3. **Default Values**: If no configuration is provided, the service uses its built-in defaults.
|
||||
|
||||
> **Tip**: If you find that changes to environment variables are not taking effect, check the **Settings** tab in the Web UI or inspect the `settings.json` file in your data directory, as it might be overriding your manual configuration.
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Variable | Flag | Description | Default |
|
||||
|------------------------------------|----------------------------|---------------------------------------------------------------------------------------------------------|---------------------------|
|
||||
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
|
||||
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
|
||||
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
|
||||
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
|
||||
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
|
||||
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>:8443` |
|
||||
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
|
||||
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
|
||||
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
|
||||
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
|
||||
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
|
||||
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
|
||||
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for `resolv.conf` migration) | `:53` |
|
||||
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
|
||||
|
||||
### Configuration Examples
|
||||
|
||||
@@ -190,23 +207,23 @@ Device migration switches your SoundTouch devices from Bose's cloud services to
|
||||
|
||||
```bash
|
||||
# Get migration summary first
|
||||
curl http://localhost:8000/setup/migration-summary/192.168.1.100
|
||||
curl http://localhost:8000/setup/devices/192.168.1.100/summary
|
||||
|
||||
# Perform migration
|
||||
curl -X POST http://localhost:8000/setup/migrate/192.168.1.100
|
||||
curl -X POST http://localhost:8000/setup/devices/192.168.1.100/migrate
|
||||
|
||||
# Verify migration status
|
||||
curl http://localhost:8000/setup/devices
|
||||
curl http://localhost:8000/devices
|
||||
```
|
||||
|
||||
#### Advanced Migration Options
|
||||
|
||||
```bash
|
||||
# Migration with proxy fallback for original services
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?proxy_url=http://localhost:8000&marge=original&stats=original"
|
||||
curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?proxy_url=http://localhost:8000&marge=original&stats=original"
|
||||
|
||||
# Migration with custom target URL
|
||||
curl -X POST "http://localhost:8000/setup/migrate/192.168.1.100?target_url=https://my-server.com:8000"
|
||||
curl -X POST "http://localhost:8000/setup/devices/192.168.1.100/migrate?target_url=https://my-server.com:8000"
|
||||
```
|
||||
|
||||
### Post-Migration Verification
|
||||
@@ -215,20 +232,89 @@ After migration, verify the device is working correctly:
|
||||
|
||||
```bash
|
||||
# Check device status
|
||||
curl http://localhost:8000/setup/devices
|
||||
curl http://localhost:8000/devices
|
||||
|
||||
# Test preset functionality
|
||||
curl "http://192.168.1.100:8090/presets"
|
||||
|
||||
# Monitor device events (if needed)
|
||||
curl "http://localhost:8000/events/192.168.1.100"
|
||||
curl "http://localhost:8000/devices/08DF1F0BA325/events"
|
||||
```
|
||||
|
||||
#### ResolvConf Migration (DHCP-Aware DNS Redirection)
|
||||
|
||||
The most robust and flexible DNS-based migration method. It utilizes the device's persistent `/mnt/nv/rc.local` script to inject a priority DNS hook into the system's DHCP configuration.
|
||||
|
||||
> **Note**: This method requires the DNS Discovery Server to be bound to **port 53** on your local IP and **actually running**. Most devices do not support custom DNS ports in `/etc/resolv.conf`. If you use a custom port for testing, remember to switch back to `:53` and ensure the server has successfully bound to it (check Settings for status) before the actual migration.
|
||||
|
||||
**Advantages:**
|
||||
- **Discovery**: Automatically discover all Bose endpoints queried by the device.
|
||||
- **Dynamic Interception**: Intercept new or unknown services without further device modifications.
|
||||
- **Fail-Safe**: Falls back to the standard network DNS (provided by your router) if the Aftertouch service is unavailable.
|
||||
- **DHCP Compatible**: Preserves your router's assigned search domain and secondary DNS servers.
|
||||
- **Wildcard Support**: Seamlessly handles `*.bose.com` redirection via your local DNS server.
|
||||
- **Persistent**: Survives reboots and DHCP renewals.
|
||||
|
||||
**How it works:**
|
||||
1. **Configuration**: A custom file named `/mnt/nv/aftertouch.resolv.conf` is created on the device's persistent partition.
|
||||
2. **Boot Hook**: On every boot, `/mnt/nv/rc.local` checks if the system's DHCP scripts (`/etc/udhcpc.d/50default` or `/opt/Bose/udhcpc.script`) have been patched.
|
||||
3. **Surgical Patch**: If not patched, it injects a one-line check into the relevant DHCP scripts.
|
||||
4. **Resolution**: Whenever the device acquires a DHCP lease, the scripts now read your `aftertouch.resolv.conf` first, placing your DNS server at the top of `/etc/resolv.conf` while keeping all other DHCP-provided settings.
|
||||
|
||||
**Setup:**
|
||||
1. Enable SSH via the `remote_services` USB trick.
|
||||
2. Create `/mnt/nv/aftertouch.resolv.conf` with your server details:
|
||||
```text
|
||||
# Created by Aftertouch/SoundTouch-Service
|
||||
# Priority nameserver for Bose service redirection
|
||||
nameserver 192.168.1.XXX
|
||||
```
|
||||
3. Update `/mnt/nv/rc.local` with the idempotent patch:
|
||||
```sh
|
||||
#!/bin/sh
|
||||
# Aftertouch DNS hook: prioritizes our custom nameserver if it exists
|
||||
HOOK_MARKER="/mnt/nv/aftertouch.resolv.conf"
|
||||
if [ -f "$HOOK_MARKER" ]; then
|
||||
# Patch 50default if it exists
|
||||
TARGET_FILE="/etc/udhcpc.d/50default"
|
||||
if [ -f "$TARGET_FILE" ] && ! grep -q "$HOOK_MARKER" "$TARGET_FILE"; then
|
||||
sed -i '/echo "search \$domain"/a \ [ -f '"$HOOK_MARKER"' ] && cat '"$HOOK_MARKER"' && dns=""' "$TARGET_FILE"
|
||||
fi
|
||||
# Patch udhcpc.script if it exists (e.g. SoundTouch 10)
|
||||
TARGET_SCRIPT="/opt/Bose/udhcpc.script"
|
||||
if [ -f "$TARGET_SCRIPT" ] && ! grep -q "$HOOK_MARKER" "$TARGET_SCRIPT"; then
|
||||
sed -i '/echo "search \$search_list # \$interface" >> \$RESOLV_CONF/a \ [ -f '"$HOOK_MARKER"' ] && cat '"$HOOK_MARKER"' >> '"\$RESOLV_CONF"' && dns=""' "$TARGET_SCRIPT"
|
||||
fi
|
||||
fi
|
||||
```
|
||||
4. Make the script executable: `chmod +x /mnt/nv/rc.local`.
|
||||
5. Reboot the speaker.
|
||||
|
||||
### DNS Discovery Server
|
||||
|
||||
The SoundTouch service includes a built-in DNS server specifically designed for Bose devices.
|
||||
|
||||
#### How it Works
|
||||
When enabled, the DNS server:
|
||||
1. Receives DNS queries from migrated SoundTouch devices.
|
||||
2. **Intercepts** known Bose domains (e.g., `api.bose.com`, `streaming.bose.com`, `bmx.bose.com`) and resolves them to the AfterTouch service IP.
|
||||
3. **Logs** all other queries for discovery purposes, allowing you to identify new Bose cloud endpoints.
|
||||
4. **Forwards** unknown or non-Bose queries to the configured upstream DNS server (default: `8.8.8.8`).
|
||||
|
||||
#### Configuration
|
||||
You can enable and configure the DNS server via the Web UI or environment variables:
|
||||
- `ENABLE_DNS_DISCOVERY=true`: Turns on the DNS server.
|
||||
- `DNS_BIND_ADDR=:53`: The port to listen on (requires root privileges for port 53).
|
||||
- `DNS_UPSTREAM=1.1.1.1`: Your preferred upstream DNS provider. **Note:** Ensure this is not set to the same address as the DNS server itself (loopback or local IP) to avoid forwarding loops. The server includes built-in loop prevention, but misconfiguration will cause forwarding to fail. DNS Discovery cannot be enabled if this setting is empty.
|
||||
|
||||
#### Manual Discovery via DNS
|
||||
Even without migrating a device, you can use the DNS server to discover what a device is querying by manually setting your router's DNS or the device's DNS to point to the AfterTouch service.
|
||||
|
||||
## API Reference
|
||||
|
||||
### Discovery & Setup
|
||||
|
||||
#### `GET /setup/devices`
|
||||
#### `GET /devices`
|
||||
Lists all discovered SoundTouch devices with their current status.
|
||||
|
||||
**Response:**
|
||||
@@ -249,10 +335,10 @@ Lists all discovered SoundTouch devices with their current status.
|
||||
#### `POST /setup/discover`
|
||||
Triggers immediate network device discovery.
|
||||
|
||||
#### `GET /setup/info/{deviceIP}`
|
||||
#### `GET /devices/{deviceIP}/info`
|
||||
Gets detailed device information and configuration.
|
||||
|
||||
#### `GET /setup/migration-summary/{deviceIP}`
|
||||
#### `GET /setup/devices/{deviceIP}/summary`
|
||||
Analyzes device configuration and provides migration preview.
|
||||
|
||||
**Response:**
|
||||
@@ -269,7 +355,7 @@ Analyzes device configuration and provides migration preview.
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /setup/migrate/{deviceIP}`
|
||||
#### `POST /setup/devices/{deviceIP}/migrate`
|
||||
Migrates device to use local services.
|
||||
|
||||
**Query Parameters:**
|
||||
@@ -280,6 +366,33 @@ Migrates device to use local services.
|
||||
- `sw_update`: Set to "original" to proxy update requests (optional)
|
||||
- `bmx`: Set to "original" to proxy BMX requests (optional)
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/revert`
|
||||
Reverts device to Bose cloud defaults.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/trust-ca`
|
||||
Injects the AfterTouch root CA into the device's trust store.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/sync`
|
||||
Syncs presets and recents from the device to local storage.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/backup`
|
||||
Creates a backup of the current device configuration.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/ensure-remote-services`
|
||||
Enables persistent SSH/remote services on the device.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/remove-remote-services`
|
||||
Removes persistent SSH/remote services from the device.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/test-connection`
|
||||
Tests HTTPS connection from device to service.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/test-hosts`
|
||||
Tests /etc/hosts redirection on the device.
|
||||
|
||||
#### `POST /setup/devices/{deviceIP}/test-dns`
|
||||
Tests DNS redirection on the device.
|
||||
|
||||
### BMX Services (Bose Media eXchange)
|
||||
|
||||
#### `GET /bmx/registry/v1/services`
|
||||
@@ -364,6 +477,15 @@ The web management interface provides a comprehensive dashboard for managing you
|
||||
- **Statistics Dashboard**: Usage and error analytics
|
||||
- **Debug Tools**: Device communication testing utilities
|
||||
|
||||
#### Interactions & Traffic Analysis
|
||||
- **Traffic Overview**: View aggregate request counts for self-handled and proxied traffic.
|
||||
- **Session Browsing**: Browse recorded interactions grouped by session.
|
||||
- **Advanced Filtering**: Filter interactions by session, category (Self/Upstream), and timestamp.
|
||||
- **Interaction Viewer**: View raw `.http` recording content directly in the browser.
|
||||
- **Session Management**: Delete individual sessions or perform bulk cleanup to keep only recent sessions.
|
||||
- **Session Download**: Download complete interaction sessions as `.tar.gz` archives for offline analysis or bug reports.
|
||||
- **DNS Discoveries**: Real-time table of all hostnames discovered via the AfterTouch DNS server, categorized by interception status (Self/Upstream).
|
||||
|
||||
### Usage Tips
|
||||
|
||||
1. **First Time Setup**: The interface will guide you through initial device discovery
|
||||
@@ -382,6 +504,7 @@ The service automatically records all HTTP interactions (both those handled loca
|
||||
- **Path-Based Structure**: Recordings are organized into subdirectories based on their URL path for better discoverability.
|
||||
- **Automatic Sanitization**: Variable path segments like IP addresses, Device IDs, and Account IDs are automatically identified and replaced with placeholders (e.g., `{{ip}}`, `{{deviceId}}`). The original values are preserved as comments at the top of the recorded `.http` files for easy identification.
|
||||
- **Re-playability**: An `http-client.env.json` file is generated for each session, allowing you to re-play the recorded requests immediately in IntelliJ IDEA.
|
||||
- **Management UI**: The **5. Interactions** tab provides a built-in viewer and management tools for all recorded data.
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -391,6 +514,8 @@ By default, the service redacts sensitive information from the recorded `.http`
|
||||
- `Authorization` headers
|
||||
- `Cookie` headers
|
||||
- `X-Bose-Token` headers
|
||||
- `X-Bose-Key` headers
|
||||
- `Proxy-Authorization` headers
|
||||
|
||||
This behavior is controlled by the `--redact-logs` flag or the `REDACT_PROXY_LOGS` environment variable.
|
||||
|
||||
@@ -440,6 +565,8 @@ data/
|
||||
│ │ └── {PATH}/
|
||||
│ │ └── {SEQ}-{TIME}-{METHOD}.http
|
||||
│ └── http-client.env.json
|
||||
├── dns/
|
||||
│ └── discoveries.json
|
||||
├── stats/
|
||||
│ ├── usage/
|
||||
│ │ └── *.json
|
||||
@@ -461,6 +588,9 @@ data/
|
||||
- **Presets.xml**: Cross-device preset synchronization
|
||||
- **Recents.xml**: Recent playback history
|
||||
|
||||
#### DNS Data (`dns/`)
|
||||
- **discoveries.json**: Persisted DNS discovery logs with hostname deduplication
|
||||
|
||||
#### Statistics (`stats/`)
|
||||
- **usage/**: Device usage analytics and patterns
|
||||
- **error/**: Error logs and diagnostic information
|
||||
@@ -511,15 +641,43 @@ find data/stats/ -name "*.json" -mtime +90 -delete
|
||||
- **Description**: Browser-based guided flow for discovery, data sync, and migration.
|
||||
|
||||
### Setup API
|
||||
- `GET /setup/devices`: List all known (auto-discovered and manual) devices.
|
||||
- `POST /setup/devices`: Manually add a device by IP.
|
||||
- `GET /devices`: List all known (auto-discovered and manual) devices.
|
||||
- `POST /devices`: Manually add a device by IP.
|
||||
- `POST /setup/discover`: Trigger a new network discovery scan.
|
||||
- `GET /setup/discovery-status`: Check if a scan is currently in progress.
|
||||
- `POST /setup/sync/{deviceIP}`: Fetch presets, recents, and sources from a device.
|
||||
- `GET /setup/summary/{deviceIP}`: Get a detailed migration readiness summary.
|
||||
- `POST /setup/migrate/{deviceIP}`: Migrate a device using the specified method (XML/Hosts).
|
||||
- `POST /devices/{deviceIP}/sync`: Fetch presets, recents, and sources from a device.
|
||||
- `GET /devices/{deviceIP}/summary`: Get a detailed migration readiness summary.
|
||||
- `POST /devices/{deviceIP}/migrate`: Migrate a device using the specified method (XML/Hosts).
|
||||
- `GET /setup/ca.crt`: Download the Root CA certificate for manual installation.
|
||||
|
||||
#### `GET /setup/interactions`
|
||||
Lists recorded interactions with optional filtering.
|
||||
|
||||
**Query Parameters:**
|
||||
- `session`: Filter by session ID (optional)
|
||||
- `category`: Filter by category (`self` or `upstream`) (optional)
|
||||
- `since`: Filter by timestamp (e.g., `2026-02-15 15:00:00`) (optional)
|
||||
|
||||
#### `GET /setup/interaction-stats`
|
||||
Returns aggregate statistics about recorded interactions across all sessions.
|
||||
|
||||
#### `GET /setup/interaction-content?file={path}`
|
||||
Returns the raw content of a specific recorded `.http` file.
|
||||
|
||||
#### `DELETE /setup/interactions/sessions/{sessionID}`
|
||||
Deletes all recordings associated with a specific session.
|
||||
|
||||
#### `DELETE /setup/interactions/sessions?keep={N}`
|
||||
Bulk cleanup: deletes all but the most recent `N` sessions.
|
||||
|
||||
### DNS Discovery API
|
||||
|
||||
#### `GET /setup/dns-discoveries`
|
||||
Returns merged in-memory and persisted DNS discoveries, sorted by last seen timestamp.
|
||||
|
||||
#### `DELETE /setup/dns-discoveries`
|
||||
Clears all recorded DNS discovery data from memory and disk.
|
||||
|
||||
### Emulated Services
|
||||
- `/bmx/registry/v1/services`: BMX service registry.
|
||||
- `/bmx/tunein/v1/*`: TuneIn radio emulation.
|
||||
@@ -636,7 +794,7 @@ soundtouch:
|
||||
name: "Living Room Speaker"
|
||||
|
||||
rest:
|
||||
- resource: "http://localhost:8000/setup/devices"
|
||||
- resource: "http://localhost:8000/devices"
|
||||
scan_interval: 60
|
||||
sensor:
|
||||
- name: "SoundTouch Devices"
|
||||
|
||||
@@ -418,10 +418,10 @@ soundtouch-cli -host <discovered-ip> -bass # Verify final state
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **[API Endpoints Overview](API-Endpoints-Overview.md)** - Complete API reference
|
||||
- **[API Endpoints Overview](API-ENDPOINTS.md)** - Complete API reference
|
||||
- **[Volume Controls](VOLUME-CONTROLS.md)** - Related audio control documentation
|
||||
- **[Client Usage Examples](../cmd/soundtouch-cli/main.go)** - CLI implementation reference
|
||||
- **[Models](../pkg/models/bass.go)** - Bass model implementation
|
||||
- **[Client Usage Examples](../../cmd/soundtouch-cli/main.go)** - CLI implementation reference
|
||||
- **[Models](../../pkg/models/bass.go)** - Bass model implementation
|
||||
|
||||
## API Compliance
|
||||
|
||||
@@ -443,4 +443,4 @@ The implementation follows the official SoundTouch API:
|
||||
**Implementation Date**: 2026-01-09
|
||||
**Status**: ✅ Complete and tested
|
||||
**Real Device Validation**: SoundTouch 10, SoundTouch 20
|
||||
**API Compliance**: Full compliance with SoundTouch Web API specification
|
||||
**API Compliance**: Full compliance with SoundTouch Web API specification
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Bose SoundTouch Cloud API Emulation (Marge/BMX/Stats)
|
||||
|
||||
This document describes the cloud-emulation APIs provided by the SoundTouch service. These APIs mimic the Bose cloud services (Marge, BMX, Stats) that SoundTouch devices and the SoundTouch controller application (Stockholm) interact with.
|
||||
|
||||
## Marge API (Account & Configuration)
|
||||
|
||||
Base path: `/marge`
|
||||
|
||||
### GET /streaming/sourceproviders
|
||||
Retrieves a list of available streaming source providers.
|
||||
|
||||
### GET /accounts/{accountId}/full
|
||||
Retrieves the full account configuration including sources, presets, and devices.
|
||||
|
||||
### GET /streaming/account/{accountId}/emailaddress
|
||||
Retrieves the email address associated with the account.
|
||||
|
||||
### GET /streaming/device_setting/account/{accountId}/device/{deviceId}/device_settings
|
||||
Retrieves settings for a specific device (e.g., clock format).
|
||||
|
||||
### POST /streaming/device_setting/account/{accountId}/device/{deviceId}/device_settings
|
||||
Updates settings for a specific device.
|
||||
|
||||
### POST /accounts/{accountId}/devices/{deviceId}/presets/{presetNumber}
|
||||
Updates a preset for a device.
|
||||
|
||||
### POST /accounts/{accountId}/devices/{deviceId}/recents
|
||||
Adds an item to the device's recently played history.
|
||||
|
||||
### POST /accounts/{accountId}/devices
|
||||
Adds a device to the account.
|
||||
|
||||
### DELETE /accounts/{accountId}/devices/{deviceId}
|
||||
Removes a device from the account.
|
||||
|
||||
## Customer API (Profile & Password)
|
||||
|
||||
Base path: `/customer`
|
||||
|
||||
### GET /account/{accountId}
|
||||
Retrieves the customer account profile.
|
||||
|
||||
### POST /account/{accountId}
|
||||
Updates the customer account profile.
|
||||
|
||||
### POST /account/{accountId}/password
|
||||
Changes the account password.
|
||||
|
||||
## Analytics & Stats API
|
||||
|
||||
Base path: `/v1` (App Events) or `/streaming/stats` (Device Stats)
|
||||
|
||||
### POST /v1/stapp/{deviceId}
|
||||
Endpoint called by Bose SoundTouch mobile and web applications (Stockholm) to submit event data.
|
||||
|
||||
### POST /v1/scmudc/{deviceId}
|
||||
Endpoint equivalent to `/v1/stapp/{deviceId}` sometimes used by apps or devices.
|
||||
|
||||
### POST /streaming/stats/usage
|
||||
Endpoint used by physical devices to report usage statistics.
|
||||
|
||||
### POST /streaming/stats/error
|
||||
Endpoint used by physical devices to report error statistics.
|
||||
|
||||
## BMX API (Streaming & Registry)
|
||||
|
||||
Base path: `/bmx`
|
||||
|
||||
### GET /registry/v1/services
|
||||
Retrieves the registry of available streaming services.
|
||||
|
||||
### GET /tunein/v1/playback/station/{stationID}
|
||||
Retrieves playback information for a TuneIn station.
|
||||
@@ -366,7 +366,7 @@ This implementation now provides the full preset management lifecycle:
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [API Endpoints Overview](API-Endpoints-Overview.md) - Complete API reference
|
||||
- [API Endpoints Overview](API-ENDPOINTS.md) - Complete API reference
|
||||
- [Volume Controls](VOLUME-CONTROLS.md) - Volume management
|
||||
- [Key Controls](KEY-CONTROLS.md) - Media control commands
|
||||
- [Source Selection](SOURCE-SELECTION.md) - Audio source management
|
||||
@@ -375,4 +375,4 @@ This implementation now provides the full preset management lifecycle:
|
||||
|
||||
Preset management in the Bose SoundTouch API is **intentionally read-only** by design. The API provides excellent capabilities for analyzing and understanding preset configurations, but preset creation must be done through official channels (app or device). This is a deliberate design decision that respects user control over their personal preset configurations.
|
||||
|
||||
For most use cases, reading preset information is sufficient for building applications that work with existing user configurations. For preset creation, guide users to use the official app or device controls, which provide the proper user experience and validation.
|
||||
For most use cases, reading preset information is sufficient for building applications that work with existing user configurations. For preset creation, guide users to use the official app or device controls, which provide the proper user experience and validation.
|
||||
|
||||
@@ -38,6 +38,7 @@ The Bose SoundTouch Go client provides comprehensive source selection functional
|
||||
- `IHEARTRADIO` - iHeartRadio streaming
|
||||
- `STORED_MUSIC` - Local/network stored music
|
||||
- `AIRPLAY` - Apple AirPlay (device dependent)
|
||||
- `RADIO_BROWSER` - [RadioBrowser](radio-browser.md) internet radio directory
|
||||
|
||||
## Client Library Usage
|
||||
|
||||
@@ -345,13 +346,13 @@ The implementation follows the official SoundTouch API:
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **[API Endpoints Overview](API-Endpoints-Overview.md)** - Complete API reference
|
||||
- **[Sources](../pkg/models/sources.go)** - Source model implementation
|
||||
- **[Now Playing](../pkg/models/nowplaying.go)** - ContentItem model
|
||||
- **[Client Usage Examples](../cmd/soundtouch-cli/main.go)** - CLI implementation reference
|
||||
- **[API Endpoints Overview](API-ENDPOINTS.md)** - Complete API reference
|
||||
- **[Sources](../../pkg/models/sources.go)** - Source model implementation
|
||||
- **[Now Playing](../../pkg/models/nowplaying.go)** - ContentItem model
|
||||
- **[Client Usage Examples](../../cmd/soundtouch-cli/main.go)** - CLI implementation reference
|
||||
|
||||
---
|
||||
|
||||
**Implementation Date**: 2026-01-09
|
||||
**Status**: ✅ Complete and tested
|
||||
**Real Device Validation**: SoundTouch 10, SoundTouch 20
|
||||
**Real Device Validation**: SoundTouch 10, SoundTouch 20
|
||||
|
||||
@@ -68,14 +68,14 @@ func main() {
|
||||
|
||||
client := client.NewClient(config)
|
||||
|
||||
// Play TTS at current volume
|
||||
err := client.PlayTTS("Hello, this is a test message", "YOUR_APP_KEY")
|
||||
// Play TTS at current volume (language code "EN", "DE", etc.)
|
||||
err := client.PlayTTS("Hello, this is a test message", "YOUR_APP_KEY", "EN")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Play TTS at specific volume (70)
|
||||
err = client.PlayTTS("Volume test message", "YOUR_APP_KEY", 70)
|
||||
err = client.PlayTTS("Volume test message", "YOUR_APP_KEY", "EN", 70)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -277,7 +277,7 @@ You'll need to provide your own application key. The format and generation metho
|
||||
|
||||
```go
|
||||
// Doorbell notification
|
||||
client.PlayTTS("Someone is at the front door", "home-automation-key", 80)
|
||||
client.PlayTTS("Someone is at the front door", "home-automation-key", "EN", 80)
|
||||
|
||||
// Security alert
|
||||
client.PlayURL(
|
||||
@@ -311,4 +311,4 @@ soundtouch-cli speaker url --url "https://www.soundjay.com/misc/sounds/bell-ring
|
||||
4. **URL content fails**: Ensure URL is accessible and contains valid audio
|
||||
5. **Volume not restored**: May occur if device is powered off during playback
|
||||
|
||||
For more information, see the [SoundTouch WebServices API documentation](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API).
|
||||
For more information, see the [SoundTouch WebServices API documentation](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API).
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
## radio-browser.info
|
||||
|
||||
- https://www.radio-browser.info is a community driven radio station database.
|
||||
- It provides an API to access the data and allows users to submit new stations or update existing ones.
|
||||
|
||||
### Search for stations
|
||||
|
||||
- Go to https://www.radio-browser.info and find a station you like.
|
||||
- Click on the station and copy the UUID from the URL.
|
||||
- e.g. `https://www.radio-browser.info/history/d28420a4-eccf-47a2-ace1-088c7e7cb7e0`
|
||||
|
||||
### RADIO_BROWSER
|
||||
|
||||
- This project supports source type RADIO_BROWSER to play radio stations.
|
||||
- Set the `location` attribute to `/stations/byuuid/{UUID}`.
|
||||
|
||||
```xml
|
||||
<ContentItem
|
||||
source="RADIO_BROWSER"
|
||||
type="stationurl"
|
||||
isPresetable="true"
|
||||
location="/stations/byuuid/9610c454-0601-11e8-ae97-52543be04c81">
|
||||
<itemName>RADIO_BROWSER</itemName>
|
||||
<containerArt></containerArt>
|
||||
</ContentItem>
|
||||
```
|
||||
|
||||
### Playing the station
|
||||
|
||||
To start the radio stream replace `<uuid>` and `<soundtouch>` and run curl like this:
|
||||
|
||||
```bash
|
||||
curl -d '<ContentItem source="RADIO_BROWSER" type="stationurl" location="/stations/byuuid/<uuid>"/>' <soundtouch>:8090/select
|
||||
```
|
||||
@@ -149,4 +149,4 @@ After configuring accounts:
|
||||
3. Use `browse` commands to explore content
|
||||
4. Use `play` commands to start playback
|
||||
|
||||
See the [CLI Reference](../../docs/CLI-REFERENCE.md) for complete documentation.
|
||||
See the [CLI Reference](../../docs/guides/CLI-REFERENCE.md) for complete documentation.
|
||||
|
||||
@@ -176,5 +176,5 @@ The example gracefully handles missing services:
|
||||
## Related Documentation
|
||||
|
||||
- [SoundTouch WebServices API Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
|
||||
- [CLI Reference](../../docs/CLI-REFERENCE.md)
|
||||
- [Navigation Guide](../../docs/NAVIGATION-GUIDE.md)
|
||||
- [CLI Reference](../../docs/guides/CLI-REFERENCE.md)
|
||||
- [Navigation Guide](../../docs/guides/SURVIVAL-GUIDE.md)
|
||||
|
||||
@@ -177,11 +177,11 @@ if err != nil {
|
||||
|
||||
This introspect data is useful before:
|
||||
- [Preset Management](../preset-management/) - Verify service state before storing presets
|
||||
- [Content Selection](../../docs/SOURCE-SELECTION.md) - Check capabilities before switching sources
|
||||
- [Zone Management](../../docs/zone-management.md) - Ensure all devices support the service
|
||||
- [Content Selection](../../docs/reference/SOURCE-SELECTION.md) - Check capabilities before switching sources
|
||||
- [Zone Management](../../docs/reference/ZONE-MANAGEMENT.md) - Ensure all devices support the service
|
||||
|
||||
## API Documentation
|
||||
|
||||
For complete API documentation, see:
|
||||
- [API Reference](../../docs/API-Endpoints-Overview.md)
|
||||
- [Service Availability Implementation](../../docs/SERVICE-AVAILABILITY-IMPLEMENTATION.md)
|
||||
- [API Reference](../../docs/reference/API-ENDPOINTS.md)
|
||||
- [Service Availability Implementation](../../docs/SERVICE-AVAILABILITY-IMPLEMENTATION.md)
|
||||
|
||||
@@ -275,10 +275,10 @@ go run ./cmd/soundtouch-cli --host 192.168.1.100 info
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [CLI Reference](../../docs/CLI-REFERENCE.md) - Browse and station commands
|
||||
- [Navigation Guide](../../docs/NAVIGATION-GUIDE.md) - Comprehensive navigation documentation
|
||||
- [CLI Reference](../../docs/guides/CLI-REFERENCE.md) - Browse and station commands
|
||||
- [Navigation Guide](../../docs/guides/SURVIVAL-GUIDE.md) - Comprehensive navigation documentation
|
||||
- [Navigation API Reference](../../docs/API-NAVIGATION-REFERENCE.md) - Technical API details
|
||||
- [WebSocket Events](../../docs/websocket-events.md) - Real-time event handling
|
||||
- [WebSocket Events](../../docs/reference/WEBSOCKET-EVENTS.md) - Real-time event handling
|
||||
|
||||
## Use Cases
|
||||
|
||||
@@ -288,4 +288,4 @@ This example demonstrates patterns for:
|
||||
- **Direct Playback**: Play content without storing as presets first
|
||||
- **Content Exploration**: Browse large music libraries efficiently
|
||||
- **Smart Home Integration**: Programmatically start specific content
|
||||
- **Personalized Experiences**: Access account-specific content from streaming services
|
||||
- **Personalized Experiences**: Access account-specific content from streaming services
|
||||
|
||||
@@ -256,10 +256,10 @@ Error: All preset slots are occupied
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [CLI Reference](../../docs/CLI-REFERENCE.md) - Command-line usage
|
||||
- [CLI Reference](../../docs/guides/CLI-REFERENCE.md) - Command-line usage
|
||||
- [Preset Implementation Guide](../../docs/preset-store.md) - Technical details
|
||||
- [WebSocket Events](../../docs/websocket-events.md) - Real-time event handling
|
||||
- [API Reference](../../docs/API-Endpoints-Overview.md) - Complete API documentation
|
||||
- [WebSocket Events](../../docs/reference/WEBSOCKET-EVENTS.md) - Real-time event handling
|
||||
- [API Reference](../../docs/reference/API-ENDPOINTS.md) - Complete API documentation
|
||||
|
||||
## Use Cases
|
||||
|
||||
@@ -269,4 +269,4 @@ This example demonstrates patterns for:
|
||||
- **Music Management**: Organize favorite content into quick-access presets
|
||||
- **Family Scenarios**: Each person gets their own preset slots
|
||||
- **Party Mode**: Pre-configure playlists for different moods
|
||||
- **Radio Favorites**: Save frequently listened radio stations
|
||||
- **Radio Favorites**: Save frequently listened radio stations
|
||||
|
||||
@@ -252,8 +252,8 @@ go run main.go -host 192.168.1.100 -type unknown
|
||||
|
||||
This recents data is useful for:
|
||||
- [Preset Management](../preset-management/) - Finding presetable content to save
|
||||
- [Content Selection](../../docs/SOURCE-SELECTION.md) - Understanding usage patterns
|
||||
- [Navigation](../../docs/NAVIGATION-GUIDE.md) - Quickly accessing recently played content
|
||||
- [Content Selection](../../docs/reference/SOURCE-SELECTION.md) - Understanding usage patterns
|
||||
- [Navigation](../../docs/guides/SURVIVAL-GUIDE.md) - Quickly accessing recently played content
|
||||
|
||||
## Related CLI Commands
|
||||
|
||||
@@ -274,6 +274,6 @@ soundtouch-cli --host 192.168.1.100 recents latest
|
||||
## API Documentation
|
||||
|
||||
For complete API documentation, see:
|
||||
- [API Reference](../../docs/API-Endpoints-Overview.md)
|
||||
- [CLI Reference](../../docs/CLI-REFERENCE.md)
|
||||
- [Recents Models](../../pkg/models/recents.go)
|
||||
- [API Reference](../../docs/reference/API-ENDPOINTS.md)
|
||||
- [CLI Reference](../../docs/guides/CLI-REFERENCE.md)
|
||||
- [Recents Models](../../pkg/models/recents.go)
|
||||
|
||||
@@ -6,18 +6,18 @@ require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hashicorp/mdns v1.0.6
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/russross/blackfriday/v2 v2.1.0
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.47.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
)
|
||||
|
||||
@@ -24,16 +24,16 @@ 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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
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.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
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=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
@@ -44,8 +44,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -79,8 +79,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.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
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=
|
||||
@@ -98,6 +98,6 @@ golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -1769,8 +1769,8 @@ func (c *Client) hasCapability(capabilities *models.Capabilities, capability str
|
||||
}
|
||||
|
||||
// PlayTTS plays a Text-To-Speech message using Google TTS on the speaker
|
||||
func (c *Client) PlayTTS(text, appKey string, volume ...int) error {
|
||||
playInfo := models.NewTTSPlayInfo(text, appKey, volume...)
|
||||
func (c *Client) PlayTTS(text, appKey, language string, volume ...int) error {
|
||||
playInfo := models.NewTTSPlayInfo(text, appKey, language, volume...)
|
||||
|
||||
if err := playInfo.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid TTS request: %w", err)
|
||||
|
||||
@@ -356,6 +356,13 @@ func (ws *WebSocketClient) attemptReconnect(config *WebSocketConfig) {
|
||||
}
|
||||
|
||||
attempt++
|
||||
|
||||
// Check if device is reachable before attempting full WS connection to reduce log noise
|
||||
if err := ws.client.Ping(); err != nil {
|
||||
ws.logger.Printf("Reconnection attempt %d skipped: device unreachable (%v)", attempt, err)
|
||||
continue
|
||||
}
|
||||
|
||||
ws.logger.Printf("Reconnection attempt %d", attempt)
|
||||
|
||||
if err := ws.connectWithConfig(config); err != nil {
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
// Package discovery provides DNS-based discovery and interception for Bose SoundTouch devices.
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// DNSDiscovery handles DNS queries and records discovered hosts.
|
||||
type DNSDiscovery struct {
|
||||
// Configuration
|
||||
upstreamDNS string
|
||||
serviceIP string
|
||||
|
||||
// State
|
||||
discovered map[string]*DiscoveredHost
|
||||
mu sync.RWMutex
|
||||
|
||||
// Callbacks
|
||||
onNewDiscovery func(hostname string)
|
||||
|
||||
// Servers for Shutdown
|
||||
udpServer *dns.Server
|
||||
tcpServer *dns.Server
|
||||
|
||||
// Address for loop prevention
|
||||
bindAddr string
|
||||
|
||||
// Log throttling
|
||||
lastLog map[string]time.Time
|
||||
lastLogMu sync.Mutex
|
||||
}
|
||||
|
||||
// DiscoveredHost represents a host discovered via DNS queries.
|
||||
type DiscoveredHost struct {
|
||||
Hostname string `json:"hostname"`
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
QueryCount int `json:"query_count"`
|
||||
IsBoseService bool `json:"is_bose_service"`
|
||||
IsIntercepted bool `json:"is_intercepted"`
|
||||
RemoteAddr string `json:"remote_addr,omitempty"`
|
||||
}
|
||||
|
||||
// NewDNSDiscovery creates a new DNSDiscovery instance.
|
||||
func NewDNSDiscovery(upstreamDNS, serviceIP string) *DNSDiscovery {
|
||||
return &DNSDiscovery{
|
||||
upstreamDNS: upstreamDNS,
|
||||
serviceIP: serviceIP,
|
||||
discovered: make(map[string]*DiscoveredHost),
|
||||
lastLog: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
// ServeDNS implements the dns.Handler interface.
|
||||
func (d *DNSDiscovery) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
if len(r.Question) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
q := r.Question[0]
|
||||
hostname := strings.TrimSuffix(q.Name, ".")
|
||||
|
||||
remoteAddr := ""
|
||||
if w.RemoteAddr() != nil {
|
||||
remoteAddr = w.RemoteAddr().String()
|
||||
}
|
||||
|
||||
// Decide how to respond
|
||||
isIntercepted := d.shouldIntercept(hostname) || hostname == "aftertouch.test"
|
||||
|
||||
// Record discovery
|
||||
d.recordQuery(hostname, isIntercepted, remoteAddr)
|
||||
|
||||
if isIntercepted {
|
||||
// Return your service IP
|
||||
d.respondWithIP(w, r, d.serviceIP)
|
||||
d.throttledLog(fmt.Sprintf("[DNS] Intercepting %s (type %d) -> %s", hostname, q.Qtype, d.serviceIP))
|
||||
} else {
|
||||
// Forward to real DNS
|
||||
if d.upstreamDNS == "" {
|
||||
d.throttledLog("[DNS ERROR] No upstream DNS configured, cannot forward")
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
_ = w.WriteMsg(m)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
d.throttledLog(fmt.Sprintf("[DNS] Forwarding %s (type %d) to %s", hostname, q.Qtype, d.upstreamDNS))
|
||||
d.forward(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DNSDiscovery) throttledLog(msg string) {
|
||||
d.lastLogMu.Lock()
|
||||
defer d.lastLogMu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
if last, ok := d.lastLog[msg]; ok && now.Sub(last) < 10*time.Second {
|
||||
return
|
||||
}
|
||||
|
||||
d.lastLog[msg] = now
|
||||
log.Print(msg)
|
||||
}
|
||||
|
||||
// recordQuery logs a DNS query and updates the internal state.
|
||||
func (d *DNSDiscovery) recordQuery(hostname string, isIntercepted bool, remoteAddr string) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
host, exists := d.discovered[hostname]
|
||||
if !exists {
|
||||
// New discovery!
|
||||
host = &DiscoveredHost{
|
||||
Hostname: hostname,
|
||||
FirstSeen: time.Now(),
|
||||
LastSeen: time.Now(),
|
||||
QueryCount: 1,
|
||||
IsBoseService: d.isBoseRelated(hostname),
|
||||
IsIntercepted: isIntercepted,
|
||||
RemoteAddr: remoteAddr,
|
||||
}
|
||||
d.discovered[hostname] = host
|
||||
|
||||
log.Printf("[NEW DISCOVERY] %s (Bose: %v, Intercepted: %v)",
|
||||
hostname, host.IsBoseService, host.IsIntercepted)
|
||||
|
||||
if d.onNewDiscovery != nil {
|
||||
go d.onNewDiscovery(hostname)
|
||||
}
|
||||
} else {
|
||||
host.LastSeen = time.Now()
|
||||
host.QueryCount++
|
||||
|
||||
host.IsIntercepted = isIntercepted
|
||||
if remoteAddr != "" {
|
||||
host.RemoteAddr = remoteAddr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DNSDiscovery) shouldIntercept(hostname string) bool {
|
||||
// Intercept known Bose cloud services
|
||||
interceptList := []string{
|
||||
"api.bose.com",
|
||||
"marge.bose.com",
|
||||
"bmx.bose.com",
|
||||
"streaming.bose.com",
|
||||
"updates.bose.com",
|
||||
"stats.bose.com",
|
||||
"content.api.bose.io",
|
||||
"events.api.bosecm.com",
|
||||
"bose-prod.apigee.net",
|
||||
"bose-test.apigee.net",
|
||||
"worldwide.bose.com",
|
||||
"music.api.bose.com",
|
||||
"bosecm.com",
|
||||
"bose.io",
|
||||
}
|
||||
|
||||
for _, service := range interceptList {
|
||||
if strings.Contains(hostname, service) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (d *DNSDiscovery) isBoseRelated(hostname string) bool {
|
||||
return strings.Contains(hostname, "bose") ||
|
||||
strings.Contains(hostname, "soundtouch")
|
||||
}
|
||||
|
||||
func (d *DNSDiscovery) respondWithIP(w dns.ResponseWriter, r *dns.Msg, ip string) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
m.Compress = false // Embedded clients sometimes don't like compression
|
||||
m.Authoritative = true
|
||||
m.RecursionAvailable = true
|
||||
|
||||
q := r.Question[0]
|
||||
log.Printf("[DNS] Intercepted query for %s (type %d) from %s", q.Name, q.Qtype, w.RemoteAddr())
|
||||
|
||||
switch q.Qtype {
|
||||
case dns.TypeA, dns.TypeANY:
|
||||
rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN A %s", q.Name, ip))
|
||||
if err == nil {
|
||||
m.Answer = append(m.Answer, rr)
|
||||
|
||||
log.Printf("[DNS] Returning A record %s -> %s", q.Name, ip)
|
||||
} else {
|
||||
log.Printf("[DNS] Error creating A record: %v", err)
|
||||
}
|
||||
case dns.TypeAAAA:
|
||||
// Explicitly return SUCCESS with no data for AAAA to prevent fallback issues
|
||||
log.Printf("[DNS] Returning empty AAAA success (NODATA) for %s", q.Name)
|
||||
default:
|
||||
log.Printf("[DNS] Returning empty success for type %d", q.Qtype)
|
||||
}
|
||||
|
||||
if err := w.WriteMsg(m); err != nil {
|
||||
log.Printf("[DNS ERROR] Failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DNSDiscovery) forward(w dns.ResponseWriter, r *dns.Msg) {
|
||||
if len(r.Question) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
q := r.Question[0]
|
||||
|
||||
// Don't forward PTR queries for our own service IP to avoid loops or slow timeouts
|
||||
if q.Qtype == dns.TypePTR {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
|
||||
m.Rcode = dns.RcodeNameError
|
||||
if err := w.WriteMsg(m); err != nil {
|
||||
log.Printf("[DNS ERROR] Failed to write NXDOMAIN: %v", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Add port 53 if not present
|
||||
upstream := d.upstreamDNS
|
||||
if !strings.Contains(upstream, ":") {
|
||||
upstream += ":53"
|
||||
}
|
||||
|
||||
// Loop prevention: don't forward to ourselves
|
||||
if upstream == d.bindAddr || (strings.HasPrefix(upstream, "127.0.0.1:") && strings.HasSuffix(d.bindAddr, upstream[9:])) {
|
||||
d.throttledLog(fmt.Sprintf("[DNS ERROR] Refusing to forward %s to ourselves (%s)", q.Name, upstream))
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
_ = w.WriteMsg(m)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c := new(dns.Client)
|
||||
c.Timeout = 2 * time.Second
|
||||
|
||||
in, _, err := c.Exchange(r, upstream)
|
||||
if err != nil {
|
||||
d.throttledLog(fmt.Sprintf("[DNS ERROR] Forward failed for %s (type %d): %v", q.Name, q.Qtype, err))
|
||||
// Return a failure response instead of just dropping
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
|
||||
m.Rcode = dns.RcodeServerFailure
|
||||
if err := w.WriteMsg(m); err != nil {
|
||||
log.Printf("[DNS ERROR] Failed to write failure response: %v", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err := w.WriteMsg(in); err != nil {
|
||||
log.Printf("[DNS ERROR] Failed to write forwarded response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// GetDiscovered returns a map of all discovered hosts.
|
||||
func (d *DNSDiscovery) GetDiscovered() map[string]*DiscoveredHost {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
// Return copy
|
||||
result := make(map[string]*DiscoveredHost)
|
||||
for k, v := range d.discovered {
|
||||
result[k] = v
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetBoseHosts returns a slice of all discovered Bose-related hosts.
|
||||
func (d *DNSDiscovery) GetBoseHosts() []*DiscoveredHost {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
var result []*DiscoveredHost
|
||||
|
||||
for _, host := range d.discovered {
|
||||
if host.IsBoseService {
|
||||
result = append(result, host)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// SetDiscovered sets the map of discovered hosts.
|
||||
func (d *DNSDiscovery) SetDiscovered(discovered map[string]*DiscoveredHost) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
d.discovered = discovered
|
||||
}
|
||||
|
||||
// Start DNS server starts both UDP and TCP listeners
|
||||
func (d *DNSDiscovery) Start(addr string) error {
|
||||
mux := dns.NewServeMux()
|
||||
mux.HandleFunc(".", d.ServeDNS)
|
||||
|
||||
d.mu.Lock()
|
||||
d.bindAddr = addr
|
||||
d.udpServer = &dns.Server{
|
||||
Addr: addr,
|
||||
Net: "udp",
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
d.tcpServer = &dns.Server{
|
||||
Addr: addr,
|
||||
Net: "tcp",
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
// Capture server references before releasing mutex to avoid race condition
|
||||
udpServer := d.udpServer
|
||||
tcpServer := d.tcpServer
|
||||
d.mu.Unlock()
|
||||
|
||||
errChan := make(chan error, 2)
|
||||
|
||||
go func() {
|
||||
log.Printf("[DNS] UDP Discovery server starting on %s", addr)
|
||||
|
||||
if err := udpServer.ListenAndServe(); err != nil {
|
||||
errChan <- fmt.Errorf("UDP server failed: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
log.Printf("[DNS] TCP Discovery server starting on %s", addr)
|
||||
|
||||
if err := tcpServer.ListenAndServe(); err != nil {
|
||||
errChan <- fmt.Errorf("TCP server failed: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("[DNS] Discovery servers starting on %s (upstream: %s, intercept IP: %s)", addr, d.upstreamDNS, d.serviceIP)
|
||||
|
||||
// Wait for first error
|
||||
return <-errChan
|
||||
}
|
||||
|
||||
// IsRunning returns true if the DNS server is active and bound to the specified address.
|
||||
func (d *DNSDiscovery) IsRunning(addr string) bool {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
if d.udpServer == nil || d.tcpServer == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// We check if the address matches what we expect
|
||||
return d.udpServer.Addr == addr && d.tcpServer.Addr == addr
|
||||
}
|
||||
|
||||
// Shutdown stops the DNS server listeners
|
||||
func (d *DNSDiscovery) Shutdown() error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if d.udpServer != nil {
|
||||
if err := d.udpServer.Shutdown(); err != nil {
|
||||
log.Printf("[DNS] Error shutting down UDP server: %v", err)
|
||||
}
|
||||
|
||||
d.udpServer = nil
|
||||
}
|
||||
|
||||
if d.tcpServer != nil {
|
||||
if err := d.tcpServer.Shutdown(); err != nil {
|
||||
log.Printf("[DNS] Error shutting down TCP server: %v", err)
|
||||
}
|
||||
|
||||
d.tcpServer = nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func TestDNSDiscovery_Interception(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
upstreamDNS := "8.8.8.8"
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
// Test intercepting Bose service
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("api.bose.com.", dns.TypeA)
|
||||
|
||||
rw := &mockResponseWriter{}
|
||||
d.ServeDNS(rw, m)
|
||||
|
||||
if rw.msg == nil {
|
||||
t.Fatal("Expected a response message, got nil")
|
||||
}
|
||||
|
||||
if len(rw.msg.Answer) == 0 {
|
||||
t.Fatal("Expected an answer in the response")
|
||||
}
|
||||
|
||||
if a, ok := rw.msg.Answer[0].(*dns.A); ok {
|
||||
if a.A.String() != serviceIP {
|
||||
t.Errorf("Expected intercepted IP %s, got %s", serviceIP, a.A.String())
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Expected A record, got %T", rw.msg.Answer[0])
|
||||
}
|
||||
|
||||
// Test aftertouch.test
|
||||
m2 := new(dns.Msg)
|
||||
m2.SetQuestion("aftertouch.test.", dns.TypeA)
|
||||
rw2 := &mockResponseWriter{}
|
||||
d.ServeDNS(rw2, m2)
|
||||
|
||||
if rw2.msg == nil || len(rw2.msg.Answer) == 0 {
|
||||
t.Fatal("Expected response for aftertouch.test")
|
||||
}
|
||||
|
||||
if a, ok := rw2.msg.Answer[0].(*dns.A); ok {
|
||||
if a.A.String() != serviceIP {
|
||||
t.Errorf("Expected intercepted IP %s for aftertouch.test, got %s", serviceIP, a.A.String())
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Expected A record for aftertouch.test, got %T", rw2.msg.Answer[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_Forwarding(t *testing.T) {
|
||||
// This test is harder because it needs a real upstream or a mock.
|
||||
// For now, let's just test that it calls forward and record.
|
||||
serviceIP := "192.168.1.100"
|
||||
upstreamDNS := "127.0.0.1:5353" // Use a port that is likely closed or we can mock
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("google.com.", dns.TypeA)
|
||||
|
||||
rw := &mockResponseWriter{}
|
||||
|
||||
// Start a mock upstream DNS server
|
||||
mux := dns.NewServeMux()
|
||||
mux.HandleFunc("google.com.", func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
_ = w.WriteMsg(m)
|
||||
})
|
||||
ts := &dns.Server{Addr: "127.0.0.1:5353", Net: "udp", Handler: mux, ReadTimeout: 100 * time.Millisecond, WriteTimeout: 100 * time.Millisecond}
|
||||
go func() {
|
||||
_ = ts.ListenAndServe()
|
||||
}()
|
||||
defer func() { _ = ts.Shutdown() }()
|
||||
|
||||
// Give it a moment to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// We expect forward to succeed
|
||||
d.ServeDNS(rw, m)
|
||||
|
||||
d.mu.RLock()
|
||||
host, exists := d.discovered["google.com"]
|
||||
d.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
t.Error("Expected google.com to be recorded in discovery")
|
||||
}
|
||||
if host.IsBoseService {
|
||||
t.Error("google.com should not be identified as a Bose service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_StartTCP(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
upstreamDNS := "8.8.8.8"
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
addr := "127.0.0.1:5354"
|
||||
go func() {
|
||||
_ = d.Start(addr)
|
||||
}()
|
||||
|
||||
// Give it a moment to start
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Test TCP resolution
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("api.bose.com.", dns.TypeA)
|
||||
|
||||
c := new(dns.Client)
|
||||
c.Net = "tcp"
|
||||
in, _, err := c.Exchange(m, addr)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to exchange via TCP: %v", err)
|
||||
}
|
||||
|
||||
if len(in.Answer) == 0 {
|
||||
t.Fatal("Expected answer in TCP response")
|
||||
}
|
||||
|
||||
if a, ok := in.Answer[0].(*dns.A); ok {
|
||||
if a.A.String() != serviceIP {
|
||||
t.Errorf("Expected intercepted IP %s via TCP, got %s", serviceIP, a.A.String())
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Expected A record via TCP, got %T", in.Answer[0])
|
||||
}
|
||||
|
||||
// Test Shutdown
|
||||
err = d.Shutdown()
|
||||
if err != nil {
|
||||
t.Errorf("Shutdown failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify it's really shut down by trying to connect
|
||||
_, _, err = c.Exchange(m, addr)
|
||||
if err == nil {
|
||||
t.Error("Expected error after shutdown, but could still exchange")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_IsRunning(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
upstreamDNS := "8.8.8.8"
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
addr := "127.0.0.1:5355"
|
||||
|
||||
if d.IsRunning(addr) {
|
||||
t.Error("Expected IsRunning to be false before Start")
|
||||
}
|
||||
|
||||
go func() {
|
||||
_ = d.Start(addr)
|
||||
}()
|
||||
|
||||
// Give it a moment to start
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
if !d.IsRunning(addr) {
|
||||
t.Error("Expected IsRunning to be true after Start")
|
||||
}
|
||||
|
||||
if d.IsRunning("127.0.0.1:9999") {
|
||||
t.Error("Expected IsRunning to be false for wrong address")
|
||||
}
|
||||
|
||||
_ = d.Shutdown()
|
||||
|
||||
if d.IsRunning(addr) {
|
||||
t.Error("Expected IsRunning to be false after Shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
type mockResponseWriter struct {
|
||||
msg *dns.Msg
|
||||
}
|
||||
|
||||
func (m *mockResponseWriter) LocalAddr() net.Addr { return nil }
|
||||
func (m *mockResponseWriter) RemoteAddr() net.Addr { return nil }
|
||||
func (m *mockResponseWriter) WriteMsg(msg *dns.Msg) error { m.msg = msg; return nil }
|
||||
func (m *mockResponseWriter) Write([]byte) (int, error) { return 0, nil }
|
||||
func (m *mockResponseWriter) Close() error { return nil }
|
||||
func (m *mockResponseWriter) TsigStatus() error { return nil }
|
||||
func (m *mockResponseWriter) TsigTimersOnly(bool) {}
|
||||
func (m *mockResponseWriter) Hijack() {}
|
||||
|
||||
func TestDNSDiscovery_LogThrottling(t *testing.T) {
|
||||
d := NewDNSDiscovery("8.8.8.8", "192.168.1.100")
|
||||
|
||||
// Capture log output
|
||||
var logBuf strings.Builder
|
||||
oldOutput := log.Writer()
|
||||
log.SetOutput(&logBuf)
|
||||
defer log.SetOutput(oldOutput)
|
||||
|
||||
msg := "Test log message"
|
||||
d.throttledLog(msg)
|
||||
d.throttledLog(msg)
|
||||
d.throttledLog(msg)
|
||||
|
||||
count := strings.Count(logBuf.String(), msg)
|
||||
if count != 1 {
|
||||
t.Errorf("Expected log message to appear once due to throttling, but appeared %d times", count)
|
||||
}
|
||||
|
||||
// Advance time by 11 seconds to bypass throttling
|
||||
d.lastLogMu.Lock()
|
||||
d.lastLog[msg] = time.Now().Add(-11 * time.Second)
|
||||
d.lastLogMu.Unlock()
|
||||
|
||||
d.throttledLog(msg)
|
||||
count = strings.Count(logBuf.String(), msg)
|
||||
if count != 2 {
|
||||
t.Errorf("Expected log message to appear twice after advancing time, but appeared %d times", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_LoopPrevention(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
bindAddr := "127.0.0.1:53"
|
||||
upstreamDNS := "127.0.0.1:53"
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
d.bindAddr = bindAddr
|
||||
|
||||
// Capture log output to avoid panic if it's being throttled/logged
|
||||
var logBuf strings.Builder
|
||||
oldOutput := log.Writer()
|
||||
log.SetOutput(&logBuf)
|
||||
defer log.SetOutput(oldOutput)
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("google.com.", dns.TypeA)
|
||||
|
||||
rw := &mockResponseWriter{}
|
||||
d.forward(rw, m)
|
||||
|
||||
if rw.msg == nil {
|
||||
t.Fatal("Expected a response message")
|
||||
}
|
||||
|
||||
if rw.msg.Rcode != dns.RcodeServerFailure {
|
||||
t.Errorf("Expected RcodeServerFailure (2), got %d", rw.msg.Rcode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_EmptyUpstream(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
upstreamDNS := "" // Empty upstream
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
d.bindAddr = ":53"
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("google.com.", dns.TypeA)
|
||||
|
||||
rw := &mockResponseWriter{}
|
||||
d.ServeDNS(rw, m)
|
||||
|
||||
if rw.msg == nil {
|
||||
t.Fatal("Expected a response message, got nil")
|
||||
}
|
||||
|
||||
if rw.msg.Rcode != dns.RcodeServerFailure {
|
||||
t.Errorf("Expected RcodeServerFailure (2) for empty upstream, got %d", rw.msg.Rcode)
|
||||
}
|
||||
|
||||
// Verify log message (optional, but good to check it's the simplified one)
|
||||
}
|
||||
|
||||
func TestDNSDiscovery_ForwardTimeout(t *testing.T) {
|
||||
serviceIP := "192.168.1.100"
|
||||
// Use an IP that is unroutable or doesn't exist on the network to ensure timeout
|
||||
upstreamDNS := "192.0.2.1:53" // TEST-NET-1, usually non-routable
|
||||
d := NewDNSDiscovery(upstreamDNS, serviceIP)
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("google.com.", dns.TypeA)
|
||||
|
||||
rw := &mockResponseWriter{}
|
||||
|
||||
start := time.Now()
|
||||
d.forward(rw, m)
|
||||
duration := time.Since(start)
|
||||
|
||||
if duration < 2*time.Second {
|
||||
t.Errorf("Expected forward to take at least 2 seconds (timeout), but took %v", duration)
|
||||
}
|
||||
|
||||
if rw.msg == nil || rw.msg.Rcode != dns.RcodeServerFailure {
|
||||
t.Errorf("Expected RcodeServerFailure after timeout")
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,7 @@ type ServiceDeviceInfo struct {
|
||||
IPAddress string `json:"ip_address" xml:"ipAddress"`
|
||||
Name string `json:"name" xml:"name"`
|
||||
DiscoveryMethod string `json:"discovery_method,omitempty"`
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
}
|
||||
|
||||
// CustomerSupportDevice represents device information for customer support purposes.
|
||||
@@ -239,3 +240,70 @@ type DeviceEvent struct {
|
||||
MonoTime int64 `json:"monoTime"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// DeviceEventsRequest represents a request containing multiple device events (stapp/scmudc).
|
||||
type DeviceEventsRequest struct {
|
||||
Envelope struct {
|
||||
MonoTime int64 `json:"monoTime"`
|
||||
PayloadProtocolVersion string `json:"payloadProtocolVersion"`
|
||||
PayloadType string `json:"payloadType"`
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
Time string `json:"time"`
|
||||
UniqueID string `json:"uniqueId"`
|
||||
} `json:"envelope"`
|
||||
Payload struct {
|
||||
DeviceInfo struct {
|
||||
BoseID string `json:"boseID"`
|
||||
DeviceID string `json:"deviceID"`
|
||||
DeviceType string `json:"deviceType"`
|
||||
SoftwareVersion string `json:"softwareVersion"`
|
||||
} `json:"deviceInfo"`
|
||||
Events []struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
Time string `json:"time"`
|
||||
Type string `json:"type"`
|
||||
} `json:"events"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
|
||||
// DeviceSettingsResponse represents device settings.
|
||||
type DeviceSettingsResponse struct {
|
||||
XMLName xml.Name `xml:"deviceSettings"`
|
||||
Settings []DeviceSetting `xml:"deviceSetting"`
|
||||
}
|
||||
|
||||
// DeviceSetting represents a single device setting.
|
||||
type DeviceSetting struct {
|
||||
Name string `xml:"name"`
|
||||
Value string `xml:"value"`
|
||||
}
|
||||
|
||||
// AccountProfileResponse represents a customer account profile.
|
||||
type AccountProfileResponse struct {
|
||||
XMLName xml.Name `xml:"customer"`
|
||||
AccountID string `xml:"accountID"`
|
||||
Email string `xml:"email"`
|
||||
FirstName string `xml:"firstName"`
|
||||
LastName string `xml:"lastName"`
|
||||
CountryCode string `xml:"countryCode"`
|
||||
LanguageCode string `xml:"languageCode"`
|
||||
Street string `xml:"street"`
|
||||
City string `xml:"city"`
|
||||
PostalCode string `xml:"postalCode"`
|
||||
State string `xml:"state"`
|
||||
Phone string `xml:"phone"`
|
||||
MarketingOptIn bool `xml:"marketingOptIn"`
|
||||
}
|
||||
|
||||
// ChangePasswordRequest represents a request to change the account password.
|
||||
type ChangePasswordRequest struct {
|
||||
XMLName xml.Name `xml:"passwordChange"`
|
||||
OldPassword string `xml:"oldPassword"`
|
||||
NewPassword string `xml:"newPassword"`
|
||||
}
|
||||
|
||||
// EmailAddressResponse represents the account email address.
|
||||
type EmailAddressResponse struct {
|
||||
XMLName xml.Name `xml:"emailAddress"`
|
||||
Email string `xml:",chardata"`
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package models
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// Error constants for speaker validation
|
||||
@@ -57,9 +59,9 @@ func (p *PlayInfo) SetVolume(volume int) *PlayInfo {
|
||||
}
|
||||
|
||||
// NewTTSPlayInfo creates a PlayInfo for Google TTS playback
|
||||
func NewTTSPlayInfo(text, appKey string, volume ...int) *PlayInfo {
|
||||
func NewTTSPlayInfo(text, appKey, language string, volume ...int) *PlayInfo {
|
||||
// URL encode the text for Google TTS
|
||||
url := "http://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=" + text
|
||||
url := fmt.Sprintf("http://translate.google.com/translate_tts?ie=UTF-8&tl=%s&client=tw-ob&q=%s", language, url.QueryEscape(text))
|
||||
|
||||
playInfo := &PlayInfo{
|
||||
XMLName: xml.Name{Local: "play_info"},
|
||||
|
||||
@@ -35,9 +35,9 @@ func TestNewPlayInfo(t *testing.T) {
|
||||
|
||||
func TestNewTTSPlayInfo(t *testing.T) {
|
||||
// Test without volume
|
||||
playInfo := NewTTSPlayInfo("Hello World", "test-key")
|
||||
playInfo := NewTTSPlayInfo("Hello World", "test-key", "EN")
|
||||
|
||||
expectedURL := "http://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=Hello World"
|
||||
expectedURL := "http://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=Hello+World"
|
||||
if playInfo.URL != expectedURL {
|
||||
t.Errorf("Expected URL '%s', got '%s'", expectedURL, playInfo.URL)
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func TestNewTTSPlayInfo(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test with volume
|
||||
playInfoWithVolume := NewTTSPlayInfo("Hello World", "test-key", 50)
|
||||
playInfoWithVolume := NewTTSPlayInfo("Hello World", "test-key", "EN", 50)
|
||||
if playInfoWithVolume.Volume == nil || *playInfoWithVolume.Volume != 50 {
|
||||
t.Errorf("Expected Volume to be 50, got %v", playInfoWithVolume.Volume)
|
||||
}
|
||||
|
||||
@@ -304,8 +304,9 @@ type SpecialMessageType string
|
||||
|
||||
// Constants for special message types
|
||||
const (
|
||||
MessageTypeSdkInfo SpecialMessageType = "sdkInfo"
|
||||
MessageTypeUserActivity SpecialMessageType = "userActivity"
|
||||
MessageTypeSdkInfo SpecialMessageType = "sdkInfo"
|
||||
MessageTypeUserActivity SpecialMessageType = "userActivity"
|
||||
MessageTypeUserInactivity SpecialMessageType = "userInactivity"
|
||||
)
|
||||
|
||||
// SoundTouchSdkInfo represents the SDK info message sent on connection
|
||||
@@ -321,6 +322,12 @@ type UserActivityUpdate struct {
|
||||
DeviceID string `xml:"deviceID,attr"`
|
||||
}
|
||||
|
||||
// UserInactivityUpdate represents user inactivity notifications
|
||||
type UserInactivityUpdate struct {
|
||||
XMLName xml.Name `xml:"userInactivityUpdate"`
|
||||
DeviceID string `xml:"deviceID,attr"`
|
||||
}
|
||||
|
||||
// SpecialMessage represents non-updates WebSocket messages
|
||||
type SpecialMessage struct {
|
||||
Type SpecialMessageType
|
||||
@@ -604,6 +611,22 @@ func ParseSpecialMessage(data []byte) (*SpecialMessage, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Check for userInactivityUpdate
|
||||
if strings.Contains(dataStr, "<userInactivityUpdate") {
|
||||
var userInactivity UserInactivityUpdate
|
||||
if err := xml.Unmarshal(data, &userInactivity); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse userInactivityUpdate: %w", err)
|
||||
}
|
||||
|
||||
return &SpecialMessage{
|
||||
Type: MessageTypeUserInactivity,
|
||||
DeviceID: userInactivity.DeviceID,
|
||||
Data: &userInactivity,
|
||||
RawData: data,
|
||||
Timestamp: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unknown special message type: %s", dataStr)
|
||||
}
|
||||
|
||||
@@ -629,6 +652,17 @@ func (sm *SpecialMessage) GetUserActivity() *UserActivityUpdate {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserInactivity returns the parsed UserInactivity data if the message is of that type
|
||||
func (sm *SpecialMessage) GetUserInactivity() *UserInactivityUpdate {
|
||||
if sm.Type == MessageTypeUserInactivity {
|
||||
if userInactivity, ok := sm.Data.(*UserInactivityUpdate); ok {
|
||||
return userInactivity
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// String returns a string representation of the special message
|
||||
func (sm *SpecialMessage) String() string {
|
||||
switch sm.Type {
|
||||
@@ -638,6 +672,8 @@ func (sm *SpecialMessage) String() string {
|
||||
}
|
||||
case MessageTypeUserActivity:
|
||||
return fmt.Sprintf("User Activity [Device: %s]", sm.DeviceID)
|
||||
case MessageTypeUserInactivity:
|
||||
return fmt.Sprintf("User Inactivity [Device: %s]", sm.DeviceID)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Unknown Special Message - Type: %s", sm.Type)
|
||||
|
||||
@@ -148,8 +148,8 @@ func (cm *CertificateManager) GenerateCA() error {
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"SoundTouch Local Service"},
|
||||
CommonName: "SoundTouch Local Root CA",
|
||||
Organization: []string{"AfterTouch"},
|
||||
CommonName: "AfterTouch Local Root CA",
|
||||
},
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
@@ -240,7 +240,7 @@ func (cm *CertificateManager) GenerateCertificate(domains []string) ([]byte, []b
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"SoundTouch Local Service"},
|
||||
Organization: []string{"AfterTouch"},
|
||||
CommonName: domains[0],
|
||||
},
|
||||
NotBefore: notBefore,
|
||||
|
||||
@@ -41,6 +41,7 @@ var Providers = []string{
|
||||
"RADIO.COM",
|
||||
"RADIO_COM",
|
||||
"SIRIUSXM_EVEREST",
|
||||
"RADIO_BROWSER",
|
||||
}
|
||||
|
||||
// Common file and path constants used by the datastore and setup logic.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -42,12 +43,12 @@ func NewDataStore(dataDir string) *DataStore {
|
||||
|
||||
// AccountDir returns the directory path for a specific account.
|
||||
func (ds *DataStore) AccountDir(account string) string {
|
||||
return filepath.Join(ds.DataDir, account)
|
||||
return filepath.Join(ds.DataDir, "accounts", account)
|
||||
}
|
||||
|
||||
// AccountDevicesDir returns the devices directory path for a specific account.
|
||||
func (ds *DataStore) AccountDevicesDir(account string) string {
|
||||
return filepath.Join(ds.DataDir, account, constants.DevicesDir)
|
||||
return filepath.Join(ds.AccountDir(account), constants.DevicesDir)
|
||||
}
|
||||
|
||||
// AccountDeviceDir returns the directory path for a specific device within an account.
|
||||
@@ -134,6 +135,7 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) {
|
||||
accDevices := ds.listDevicesInAccount(dir, acc.Name())
|
||||
for i := range accDevices {
|
||||
info := accDevices[i]
|
||||
info.AccountID = acc.Name()
|
||||
|
||||
key := info.DeviceID
|
||||
if key == "" {
|
||||
@@ -153,13 +155,13 @@ func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) {
|
||||
|
||||
func (ds *DataStore) getPossibleDataDirs() []string {
|
||||
dirs := []string{}
|
||||
if exists(ds.DataDir) {
|
||||
dirs = append(dirs, ds.DataDir)
|
||||
if exists(filepath.Join(ds.DataDir, "accounts")) {
|
||||
dirs = append(dirs, filepath.Join(ds.DataDir, "accounts"))
|
||||
}
|
||||
|
||||
// Also check soundcork-go/data if it's different and exists
|
||||
altDir := "soundcork-go/data"
|
||||
if ds.DataDir != altDir && exists(altDir) {
|
||||
// Also check st-go/data/accounts if it's different and exists
|
||||
altDir := "st-go/data/accounts"
|
||||
if filepath.Join(ds.DataDir, "accounts") != altDir && exists(altDir) {
|
||||
dirs = append(dirs, altDir)
|
||||
}
|
||||
|
||||
@@ -638,17 +640,6 @@ func (ds *DataStore) Initialize() error {
|
||||
return fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
// Ensure default account exists
|
||||
defaultDir := ds.AccountDir("default")
|
||||
if err := os.MkdirAll(defaultDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create default account directory: %w", err)
|
||||
}
|
||||
|
||||
// Ensure devices subdirectory for default account
|
||||
if err := os.MkdirAll(ds.AccountDevicesDir("default"), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create default devices directory: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -706,6 +697,67 @@ func (ds *DataStore) GetETagForAccount(account, device string) int64 {
|
||||
return maxETag
|
||||
}
|
||||
|
||||
// Settings represents the global service settings.
|
||||
type Settings struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
SoundcorkURL string `json:"soundcork_url"`
|
||||
HTTPServerURL string `json:"https_server_url,omitempty"`
|
||||
RedactLogs bool `json:"redact_logs"`
|
||||
LogBodies bool `json:"log_bodies"`
|
||||
RecordInteractions bool `json:"record_interactions"`
|
||||
DiscoveryInterval string `json:"discovery_interval,omitempty"`
|
||||
DiscoveryEnabled bool `json:"discovery_enabled"`
|
||||
EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"`
|
||||
DNSEnabled bool `json:"dns_enabled"`
|
||||
DNSUpstream string `json:"dns_upstream,omitempty"`
|
||||
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
|
||||
Shortcuts map[string]int `json:"shortcuts,omitempty"`
|
||||
}
|
||||
|
||||
// GetSettings retrieves the global service settings.
|
||||
func (ds *DataStore) GetSettings() (Settings, error) {
|
||||
if ds == nil || ds.DataDir == "" {
|
||||
return Settings{}, nil
|
||||
}
|
||||
|
||||
path := filepath.Join(ds.DataDir, "settings.json")
|
||||
if !exists(path) {
|
||||
return Settings{}, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
|
||||
var settings Settings
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// SaveSettings saves the global service settings.
|
||||
func (ds *DataStore) SaveSettings(settings Settings) error {
|
||||
if ds == nil || ds.DataDir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(ds.DataDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(ds.DataDir, "settings.json")
|
||||
|
||||
data, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
// SaveUsageStats saves usage statistics to the datastore.
|
||||
func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error {
|
||||
dir := filepath.Join(ds.DataDir, "stats", "usage")
|
||||
@@ -774,3 +826,78 @@ func (ds *DataStore) GetDeviceEvents(deviceID string) []models.DeviceEvent {
|
||||
|
||||
return copiedEvents
|
||||
}
|
||||
|
||||
// DNSDiscoveryEntry represents a persisted DNS discovery.
|
||||
type DNSDiscoveryEntry struct {
|
||||
Hostname string `json:"hostname"`
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
QueryCount int `json:"query_count"`
|
||||
IsBoseService bool `json:"is_bose_service"`
|
||||
IsIntercepted bool `json:"is_intercepted"`
|
||||
RemoteAddr string `json:"remote_addr,omitempty"`
|
||||
}
|
||||
|
||||
// SaveDNSDiscoveries saves DNS discoveries to the datastore.
|
||||
func (ds *DataStore) SaveDNSDiscoveries(discoveries []DNSDiscoveryEntry) error {
|
||||
if ds == nil || ds.DataDir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
dir := filepath.Join(ds.DataDir, "dns")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create dns directory: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "discoveries.json")
|
||||
|
||||
// Sort by last seen descending
|
||||
sort.Slice(discoveries, func(i, j int) bool {
|
||||
return discoveries[i].LastSeen.After(discoveries[j].LastSeen)
|
||||
})
|
||||
|
||||
data, err := json.MarshalIndent(discoveries, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
// LoadDNSDiscoveries loads DNS discoveries from the datastore.
|
||||
func (ds *DataStore) LoadDNSDiscoveries() ([]DNSDiscoveryEntry, error) {
|
||||
if ds == nil || ds.DataDir == "" {
|
||||
return []DNSDiscoveryEntry{}, nil
|
||||
}
|
||||
|
||||
path := filepath.Join(ds.DataDir, "dns", "discoveries.json")
|
||||
if !exists(path) {
|
||||
return []DNSDiscoveryEntry{}, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var discoveries []DNSDiscoveryEntry
|
||||
if err := json.Unmarshal(data, &discoveries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return discoveries, nil
|
||||
}
|
||||
|
||||
// ClearDNSDiscoveries removes all DNS discoveries from the datastore.
|
||||
func (ds *DataStore) ClearDNSDiscoveries() error {
|
||||
if ds == nil || ds.DataDir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
path := filepath.Join(ds.DataDir, "dns", "discoveries.json")
|
||||
if !exists(path) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func TestDataStore(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -22,8 +22,9 @@ func TestDataStore(t *testing.T) {
|
||||
|
||||
// Test Save/Get DeviceInfo
|
||||
info := &models.ServiceDeviceInfo{
|
||||
DeviceID: device,
|
||||
Name: "Test Speaker",
|
||||
DeviceID: device,
|
||||
Name: "Test Speaker",
|
||||
AccountID: account,
|
||||
}
|
||||
|
||||
err = ds.SaveDeviceInfo(account, device, info)
|
||||
@@ -87,14 +88,14 @@ func TestDataStore(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test path helpers
|
||||
expectedAccountDir := filepath.Join(tempDir, account)
|
||||
expectedAccountDir := filepath.Join(tempDir, "accounts", account)
|
||||
if ds.AccountDir(account) != expectedAccountDir {
|
||||
t.Errorf("Expected account dir %s, got %s", expectedAccountDir, ds.AccountDir(account))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAllDevices_Empty(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-empty-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-empty-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -133,7 +134,7 @@ func TestListAllDevices_Empty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestListAllDevices(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-list-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-list-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -151,6 +152,7 @@ func TestListAllDevices(t *testing.T) {
|
||||
DeviceSerialNumber: deviceID,
|
||||
ProductCode: "SoundTouch 10",
|
||||
FirmwareVersion: "1.2.3",
|
||||
AccountID: account,
|
||||
}
|
||||
|
||||
err = ds.SaveDeviceInfo(account, deviceID, info)
|
||||
@@ -173,7 +175,7 @@ func TestListAllDevices(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestListAllDevices_EmptyDeviceID(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-empty-id-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-empty-id-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -185,8 +187,9 @@ func TestListAllDevices_EmptyDeviceID(t *testing.T) {
|
||||
deviceID := ""
|
||||
|
||||
info := &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
Name: "Empty ID Speaker",
|
||||
DeviceID: deviceID,
|
||||
Name: "Empty ID Speaker",
|
||||
AccountID: account,
|
||||
}
|
||||
|
||||
// Use IP as fallback for device ID if it is empty
|
||||
@@ -215,7 +218,7 @@ func TestListAllDevices_EmptyDeviceID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestListAllDevices_MultipleEmptyIDs(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-multi-empty-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-multi-empty-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -230,11 +233,13 @@ func TestListAllDevices_MultipleEmptyIDs(t *testing.T) {
|
||||
DeviceID: "",
|
||||
Name: "Speaker 1",
|
||||
IPAddress: "192.168.1.1",
|
||||
AccountID: account,
|
||||
}
|
||||
info2 := &models.ServiceDeviceInfo{
|
||||
DeviceID: "",
|
||||
Name: "Speaker 2",
|
||||
IPAddress: "192.168.1.2",
|
||||
AccountID: account,
|
||||
}
|
||||
|
||||
// We use the same logic as in main.go: use IP as fallback for directory name
|
||||
@@ -259,7 +264,7 @@ func TestListAllDevices_MultipleEmptyIDs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestListAllDevices_MalformedXML(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-malformed-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-malformed-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -365,3 +370,44 @@ func TestConfiguredSources(t *testing.T) {
|
||||
t.Error("Expected auto-assigned ID for source with empty ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsPersistence(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "settings-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
settings := Settings{
|
||||
ServerURL: "http://myserver:8000",
|
||||
SoundcorkURL: "http://myproxy:8001",
|
||||
LogBodies: true,
|
||||
DiscoveryInterval: "10m",
|
||||
DiscoveryEnabled: true,
|
||||
}
|
||||
|
||||
err = ds.SaveSettings(settings)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveSettings failed: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := ds.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSettings failed: %v", err)
|
||||
}
|
||||
|
||||
if loaded.ServerURL != settings.ServerURL {
|
||||
t.Errorf("Expected ServerURL %s, got %s", settings.ServerURL, loaded.ServerURL)
|
||||
}
|
||||
if loaded.LogBodies != settings.LogBodies {
|
||||
t.Errorf("Expected LogBodies %v, got %v", settings.LogBodies, loaded.LogBodies)
|
||||
}
|
||||
if loaded.DiscoveryInterval != settings.DiscoveryInterval {
|
||||
t.Errorf("Expected DiscoveryInterval %s, got %s", settings.DiscoveryInterval, loaded.DiscoveryInterval)
|
||||
}
|
||||
if loaded.DiscoveryEnabled != settings.DiscoveryEnabled {
|
||||
t.Errorf("Expected DiscoveryEnabled %v, got %v", settings.DiscoveryEnabled, loaded.DiscoveryEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDNSDiscoveryPersistence(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "datastore-dns-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
now := time.Now().Round(time.Second)
|
||||
discoveries := []DNSDiscoveryEntry{
|
||||
{
|
||||
Hostname: "api.bose.com",
|
||||
FirstSeen: now.Add(-1 * time.Hour),
|
||||
LastSeen: now,
|
||||
QueryCount: 10,
|
||||
IsBoseService: true,
|
||||
IsIntercepted: true,
|
||||
RemoteAddr: "192.168.1.100",
|
||||
},
|
||||
{
|
||||
Hostname: "google.com",
|
||||
FirstSeen: now.Add(-2 * time.Hour),
|
||||
LastSeen: now.Add(-1 * time.Hour),
|
||||
QueryCount: 5,
|
||||
IsBoseService: false,
|
||||
IsIntercepted: false,
|
||||
RemoteAddr: "192.168.1.101",
|
||||
},
|
||||
}
|
||||
|
||||
// Test Save
|
||||
err = ds.SaveDNSDiscoveries(discoveries)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveDNSDiscoveries failed: %v", err)
|
||||
}
|
||||
|
||||
// Test Load
|
||||
loaded, err := ds.LoadDNSDiscoveries()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDNSDiscoveries failed: %v", err)
|
||||
}
|
||||
|
||||
if len(loaded) != 2 {
|
||||
t.Errorf("Expected 2 discoveries, got %d", len(loaded))
|
||||
}
|
||||
|
||||
// Check if sorted by LastSeen (SaveDNSDiscoveries sorts them)
|
||||
if loaded[0].Hostname != "api.bose.com" {
|
||||
t.Errorf("Expected api.bose.com to be first, got %s", loaded[0].Hostname)
|
||||
}
|
||||
|
||||
// Test Clear
|
||||
err = ds.ClearDNSDiscoveries()
|
||||
if err != nil {
|
||||
t.Fatalf("ClearDNSDiscoveries failed: %v", err)
|
||||
}
|
||||
|
||||
loadedAfterClear, err := ds.LoadDNSDiscoveries()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDNSDiscoveries after clear failed: %v", err)
|
||||
}
|
||||
|
||||
if len(loadedAfterClear) != 0 {
|
||||
t.Errorf("Expected 0 discoveries after clear, got %d", len(loadedAfterClear))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func TestDNSSettingsValidation(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "dns-validation-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
r, server := setupRouter("http://localhost:8001", ds)
|
||||
|
||||
// Test Case 1: Enable DNS with empty upstream
|
||||
update := map[string]interface{}{
|
||||
"dns_enabled": true,
|
||||
"dns_upstream": "",
|
||||
"dns_bind_addr": ":5353",
|
||||
}
|
||||
|
||||
body, err := json.Marshal(update)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal update: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest("POST", "/setup/settings", bytes.NewBuffer(body))
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400 when enabling DNS without upstream, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Verify DNS server is NOT running
|
||||
running, _ := server.GetDNSRunning()
|
||||
if running {
|
||||
t.Error("DNS server should not be running after invalid config attempt")
|
||||
}
|
||||
|
||||
// Test Case 2: Enable DNS with valid upstream
|
||||
// Using a random port to avoid conflicts and ensure it's fast
|
||||
updateValid := map[string]interface{}{
|
||||
"dns_enabled": true,
|
||||
"dns_upstream": "8.8.8.8",
|
||||
"dns_bind_addr": "127.0.0.1:0", // Random port
|
||||
}
|
||||
|
||||
bodyValid, err := json.Marshal(updateValid)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal updateValid: %v", err)
|
||||
}
|
||||
reqValid := httptest.NewRequest("POST", "/setup/settings", bytes.NewBuffer(bodyValid))
|
||||
wValid := httptest.NewRecorder()
|
||||
r.ServeHTTP(wValid, reqValid)
|
||||
|
||||
if wValid.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200 when enabling DNS with valid upstream, got %d. Body: %s", wValid.Code, wValid.Body.String())
|
||||
}
|
||||
|
||||
// Verify DNS state in server
|
||||
if !server.dnsEnabled {
|
||||
t.Error("DNS should be enabled in server state")
|
||||
}
|
||||
|
||||
// Shutdown server to clean up
|
||||
if server.dnsDiscovery != nil {
|
||||
_ = server.dnsDiscovery.Shutdown()
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ const normalizedEtag = "Etag"
|
||||
const caseSensitiveETag = "ETag"
|
||||
|
||||
func TestMargeETags(t *testing.T) {
|
||||
tempDir, _ := os.MkdirTemp("", "soundcork-etag-test-*")
|
||||
tempDir, _ := os.MkdirTemp("", "st-etag-test-*")
|
||||
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestMargeETags(t *testing.T) {
|
||||
|
||||
account := "12345"
|
||||
deviceID := "DEV1"
|
||||
accountDir := filepath.Join(tempDir, account)
|
||||
accountDir := filepath.Join(tempDir, "accounts", account)
|
||||
deviceDir := filepath.Join(accountDir, "devices", deviceID)
|
||||
_ = os.MkdirAll(deviceDir, 0755)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestEventLog(t *testing.T) {
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/streaming/stats/usage", s.HandleUsageStats)
|
||||
r.Get("/setup/devices/{deviceId}/events", s.HandleGetDeviceEvents)
|
||||
r.Get("/devices/{deviceId}/events", s.HandleGetDeviceEvents)
|
||||
|
||||
t.Run("Record and Retrieve Events", func(t *testing.T) {
|
||||
// 1. Post a usage stat
|
||||
@@ -36,7 +36,7 @@ func TestEventLog(t *testing.T) {
|
||||
}
|
||||
|
||||
// 2. Retrieve events
|
||||
req, _ = http.NewRequest("GET", "/setup/devices/SPEAKER1/events", nil)
|
||||
req, _ = http.NewRequest("GET", "/devices/SPEAKER1/events", nil)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
|
||||
@@ -60,6 +60,85 @@ func (s *Server) HandleMargePowerOn(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// HandleMargeAccountProfile returns the account profile.
|
||||
func (s *Server) HandleMargeAccountProfile(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := chi.URLParam(r, "account")
|
||||
|
||||
// Mock profile data
|
||||
profile := models.AccountProfileResponse{
|
||||
AccountID: accountID,
|
||||
Email: "user@example.com",
|
||||
FirstName: "SoundTouch",
|
||||
LastName: "User",
|
||||
CountryCode: "US",
|
||||
LanguageCode: "en",
|
||||
}
|
||||
|
||||
data, err := xml.MarshalIndent(profile, "", " ")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(xml.Header))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeUpdateAccountProfile updates the account profile.
|
||||
func (s *Server) HandleMargeUpdateAccountProfile(w http.ResponseWriter, _ *http.Request) {
|
||||
// Stub implementation
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// HandleMargeChangePassword changes the account password.
|
||||
func (s *Server) HandleMargeChangePassword(w http.ResponseWriter, _ *http.Request) {
|
||||
// Stub implementation
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// HandleMargeGetEmailAddress returns the account email address.
|
||||
func (s *Server) HandleMargeGetEmailAddress(w http.ResponseWriter, _ *http.Request) {
|
||||
resp := models.EmailAddressResponse{
|
||||
Email: "user@example.com",
|
||||
}
|
||||
|
||||
data, err := xml.MarshalIndent(resp, "", " ")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(xml.Header))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeGetDeviceSettings returns device settings.
|
||||
func (s *Server) HandleMargeGetDeviceSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
resp := models.DeviceSettingsResponse{
|
||||
Settings: []models.DeviceSetting{
|
||||
{Name: "CLOCK_FORMAT", Value: "24HR"},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := xml.MarshalIndent(resp, "", " ")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(xml.Header))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeUpdateDeviceSettings updates device settings.
|
||||
func (s *Server) HandleMargeUpdateDeviceSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
// Stub implementation
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// HandleMargeSoftwareUpdate returns the Marge software update information.
|
||||
func (s *Server) HandleMargeSoftwareUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
etag := "default-embedded"
|
||||
@@ -202,11 +281,22 @@ func (s *Server) HandleMargeProviderSettings(w http.ResponseWriter, r *http.Requ
|
||||
func (s *Server) HandleMargeStreamingToken(w http.ResponseWriter, _ *http.Request) {
|
||||
// Simple mock token for offline use.
|
||||
// In a real production environment, this would be a JWT or similar signed token.
|
||||
// Some speakers might expect a specific format; soundcork uses a distinctive prefix
|
||||
// Some speakers might expect a specific format; we use a distinctive prefix
|
||||
// to indicate it's a locally generated token.
|
||||
token := "soundcork-local-token-" + strconv.FormatInt(time.Now().Unix(), 10)
|
||||
w.Header().Set("Authorization", "Bearer "+token)
|
||||
tokenValue := "st-local-token-" + strconv.FormatInt(time.Now().Unix(), 10)
|
||||
bearerToken := models.NewBearerToken(tokenValue)
|
||||
|
||||
data, err := xml.Marshal(bearerToken)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
|
||||
w.Header().Set("Authorization", bearerToken.GetAuthHeader())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(xml.Header))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// HandleMargeCustomerSupport handles Marge customer support uploads.
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMargeStockholmHandlers(t *testing.T) {
|
||||
r, _ := setupRouter("http://localhost:8001", nil)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
t.Run("HandleMargeAccountProfile GET", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/customer/account/12345")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(body), "<accountID>12345</accountID>") {
|
||||
t.Errorf("Response missing account ID: %s", string(body))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HandleMargeUpdateAccountProfile POST", func(t *testing.T) {
|
||||
res, err := http.Post(ts.URL+"/customer/account/12345", "application/xml", strings.NewReader("<profile/>"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HandleMargeChangePassword POST", func(t *testing.T) {
|
||||
res, err := http.Post(ts.URL+"/customer/account/12345/password", "application/xml", strings.NewReader("<password/>"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HandleMargeGetEmailAddress GET", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/marge/streaming/account/12345/emailaddress")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(body), "user@example.com") {
|
||||
t.Errorf("Response missing email: %s", string(body))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HandleMargeGetDeviceSettings GET", func(t *testing.T) {
|
||||
res, err := http.Get(ts.URL + "/marge/streaming/device_setting/account/123/device/DEV1/device_settings")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(body), "CLOCK_FORMAT") {
|
||||
t.Errorf("Response missing settings: %s", string(body))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HandleMargeUpdateDeviceSettings POST", func(t *testing.T) {
|
||||
res, err := http.Post(ts.URL+"/marge/streaming/device_setting/account/123/device/DEV1/device_settings", "application/xml", strings.NewReader("<settings/>"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func TestMargeSoftwareUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMargeAccountFull(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func TestMargeAccountFull(t *testing.T) {
|
||||
|
||||
account := "12345"
|
||||
deviceID := "ABCDE"
|
||||
accountDir := filepath.Join(tempDir, account)
|
||||
accountDir := filepath.Join(tempDir, "accounts", account)
|
||||
|
||||
deviceDir := filepath.Join(accountDir, "devices", deviceID)
|
||||
err = os.MkdirAll(deviceDir, 0755)
|
||||
@@ -125,7 +125,7 @@ func TestMargeAccountFull(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMargePresets(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
@@ -137,7 +137,7 @@ func TestMargePresets(t *testing.T) {
|
||||
account := "12345"
|
||||
deviceID := "any"
|
||||
|
||||
accountDir := filepath.Join(tempDir, account)
|
||||
accountDir := filepath.Join(tempDir, "accounts", account)
|
||||
deviceDir := filepath.Join(accountDir, "devices", deviceID)
|
||||
err = os.MkdirAll(deviceDir, 0755)
|
||||
|
||||
@@ -196,7 +196,7 @@ func TestMargePresets(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMargeUpdatePreset(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
@@ -208,7 +208,7 @@ func TestMargeUpdatePreset(t *testing.T) {
|
||||
account := "12345"
|
||||
deviceID := "DEV1"
|
||||
|
||||
accountDir := filepath.Join(tempDir, account)
|
||||
accountDir := filepath.Join(tempDir, "accounts", account)
|
||||
deviceDir := filepath.Join(accountDir, "devices", deviceID)
|
||||
err = os.MkdirAll(deviceDir, 0755)
|
||||
|
||||
@@ -265,7 +265,7 @@ func TestMargeUpdatePreset(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMargeDeviceInfo(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
@@ -277,7 +277,7 @@ func TestMargeDeviceInfo(t *testing.T) {
|
||||
account := "12345"
|
||||
deviceID := "DEV1"
|
||||
|
||||
accountDir := filepath.Join(tempDir, account)
|
||||
accountDir := filepath.Join(tempDir, "accounts", account)
|
||||
deviceDir := filepath.Join(accountDir, "devices", deviceID)
|
||||
err = os.MkdirAll(deviceDir, 0755)
|
||||
|
||||
@@ -332,7 +332,7 @@ func TestMargeDeviceInfo(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMargeAddRemoveDevice(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
@@ -343,7 +343,7 @@ func TestMargeAddRemoveDevice(t *testing.T) {
|
||||
|
||||
account := "12345"
|
||||
|
||||
accountDir := filepath.Join(tempDir, account)
|
||||
accountDir := filepath.Join(tempDir, "accounts", account)
|
||||
err = os.MkdirAll(accountDir, 0755)
|
||||
|
||||
if err != nil {
|
||||
@@ -427,7 +427,7 @@ func TestMargePowerOn(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMargeAdvancedFeatures(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
@@ -471,10 +471,23 @@ func TestMargeAdvancedFeatures(t *testing.T) {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
|
||||
contentType := res.Header.Get("Content-Type")
|
||||
if contentType != "application/vnd.bose.streaming-v1.2+xml" {
|
||||
t.Errorf("Invalid content type: %s", contentType)
|
||||
}
|
||||
|
||||
token := res.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(token, "Bearer soundcork-local-token-") {
|
||||
if !strings.HasPrefix(token, "Bearer st-local-token-") {
|
||||
t.Errorf("Invalid token header: %s", token)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(body), "<bearertoken") {
|
||||
t.Errorf("Response body missing <bearertoken: %s", body)
|
||||
}
|
||||
if !strings.Contains(string(body), token) {
|
||||
t.Errorf("Response body missing token value: %s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CustomerSupport", func(t *testing.T) {
|
||||
|
||||
@@ -11,16 +11,16 @@ import (
|
||||
//go:embed web/index.html
|
||||
var indexHTML []byte
|
||||
|
||||
//go:embed web/css/* web/js/*
|
||||
//go:embed web/migration/* web/stockholm-mini/* web/shared/*
|
||||
var webFS embed.FS
|
||||
|
||||
//go:embed soundcork/media/*
|
||||
//go:embed static/media/*
|
||||
var mediaFS embed.FS
|
||||
|
||||
//go:embed soundcork/bmx_services.json
|
||||
//go:embed static/bmx_services.json
|
||||
var bmxServicesJSON []byte
|
||||
|
||||
//go:embed soundcork/swupdate.xml
|
||||
//go:embed static/swupdate.xml
|
||||
var swUpdateXML []byte
|
||||
|
||||
// HandleRoot returns the root endpoint response.
|
||||
@@ -28,7 +28,7 @@ func (s *Server) HandleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
accept := r.Header.Get("Accept")
|
||||
if !strings.Contains(accept, "text/html") && (strings.Contains(accept, "application/json") || accept == "*/*" || accept == "") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(w, `{"Bose": "Can't Brick Us", "service": "Go/Chi"}`)
|
||||
_, _ = fmt.Fprintf(w, `{"Bose": "AfterTouch", "service": "Go/Chi", "docs": "https://gesellix.github.io/Bose-SoundTouch/"}`)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func (s *Server) HandleWeb() http.HandlerFunc {
|
||||
|
||||
// HandleMedia returns a handler for serving media files.
|
||||
func (s *Server) HandleMedia() http.HandlerFunc {
|
||||
subFS, _ := fs.Sub(mediaFS, "soundcork/media")
|
||||
subFS, _ := fs.Sub(mediaFS, "static/media")
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
fs := http.StripPrefix("/media/", http.FileServer(http.FS(subFS)))
|
||||
|
||||
@@ -35,8 +35,8 @@ func TestRootEndpoint(t *testing.T) {
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if !strings.Contains(string(body), "Bose SoundTouch Toolkit") {
|
||||
t.Errorf("Expected body to contain 'Bose SoundTouch Toolkit', got %s", string(body))
|
||||
if !strings.Contains(string(body), "AfterTouch") {
|
||||
t.Errorf("Expected body to contain 'AfterTouch', got %s", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,8 +67,7 @@ func TestRootEndpointJSON(t *testing.T) {
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
|
||||
expected := `{"Bose": "Can't Brick Us", "service": "Go/Chi"}`
|
||||
expected := `{"Bose": "AfterTouch", "service": "Go/Chi", "docs": "https://gesellix.github.io/Bose-SoundTouch/"}`
|
||||
if strings.TrimSpace(string(body)) != expected {
|
||||
t.Errorf("Expected body %s, got %s", expected, string(body))
|
||||
}
|
||||
@@ -80,7 +79,7 @@ func TestStaticMedia(t *testing.T) {
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// Use a known file from soundcork/media
|
||||
// Use a known file from static/media
|
||||
res, err := http.Get(ts.URL + "/media/SiriusXM_Logo_Color.svg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -104,32 +103,103 @@ func TestStaticWeb(t *testing.T) {
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// 1. Test CSS
|
||||
res, err := http.Get(ts.URL + "/web/css/style.css")
|
||||
// 1. Test Migration UI CSS
|
||||
res, err := http.Get(ts.URL + "/web/migration/style.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("CSS: Expected status OK, got %v", res.Status)
|
||||
t.Errorf("Migration CSS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
|
||||
t.Errorf("CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
t.Errorf("Migration CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 2. Test JS
|
||||
res, err = http.Get(ts.URL + "/web/js/script.js")
|
||||
// 2. Test Migration UI JS
|
||||
res, err = http.Get(ts.URL + "/web/migration/script.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("JS: Expected status OK, got %v", res.Status)
|
||||
t.Errorf("Migration JS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
|
||||
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
|
||||
t.Errorf("JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
|
||||
t.Errorf("Migration JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 3. Test Migration UI Index
|
||||
res, err = http.Get(ts.URL + "/web/migration/index.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Migration Index: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/html") {
|
||||
t.Errorf("Migration Index: Expected text/html content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 4. Test Stockholm Mini
|
||||
res, err = http.Get(ts.URL + "/web/stockholm-mini/index.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Stockholm Mini: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/html") {
|
||||
t.Errorf("Stockholm Mini: Expected text/html content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 5. Test Stockholm Mini CSS
|
||||
res, err = http.Get(ts.URL + "/web/stockholm-mini/style.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Stockholm Mini CSS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
|
||||
t.Errorf("Stockholm Mini CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 6. Test Shared CSS
|
||||
res, err = http.Get(ts.URL + "/web/shared/common.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Shared CSS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "text/css") {
|
||||
t.Errorf("Shared CSS: Expected text/css content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 7. Test Shared JS
|
||||
res, err = http.Get(ts.URL + "/web/shared/common.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Shared JS: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
if !strings.Contains(res.Header.Get("Content-Type"), "application/javascript") &&
|
||||
!strings.Contains(res.Header.Get("Content-Type"), "text/javascript") {
|
||||
t.Errorf("Shared JS: Expected javascript content type, got %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// BasicAuthMgmt returns a Basic Auth middleware using the server's management credentials.
|
||||
func (s *Server) BasicAuthMgmt() func(http.Handler) http.Handler {
|
||||
s.mu.RLock()
|
||||
username := s.mgmtUsername
|
||||
password := s.mgmtPassword
|
||||
s.mu.RUnlock()
|
||||
|
||||
return middleware.BasicAuth("Management API", map[string]string{username: password})
|
||||
}
|
||||
|
||||
// HandleMgmtListSpeakers returns discovered speakers for the given account.
|
||||
func (s *Server) HandleMgmtListSpeakers(w http.ResponseWriter, r *http.Request) {
|
||||
_ = chi.URLParam(r, "accountId")
|
||||
|
||||
allDevices, err := s.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
log.Printf("[Mgmt] Failed to list devices: %v", err)
|
||||
|
||||
allDevices = nil
|
||||
}
|
||||
|
||||
type speaker struct {
|
||||
IPAddress string `json:"ipAddress"`
|
||||
Name string `json:"name"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
speakers := make([]speaker, 0, len(allDevices))
|
||||
for i := range allDevices {
|
||||
d := &allDevices[i]
|
||||
speakers = append(speakers, speaker{
|
||||
IPAddress: d.IPAddress,
|
||||
Name: d.Name,
|
||||
DeviceID: d.DeviceID,
|
||||
Type: d.ProductCode,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"speakers": speakers,
|
||||
}); err != nil {
|
||||
log.Printf("[Mgmt] Failed to encode speakers: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMgmtDeviceEvents returns events for a device (currently a placeholder).
|
||||
func (s *Server) HandleMgmtDeviceEvents(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
|
||||
events := s.ds.GetDeviceEvents(deviceID)
|
||||
if events == nil {
|
||||
events = nil // will marshal as empty array via wrapper
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Return the events in the structure the Flutter app expects.
|
||||
// Use an explicit empty slice to ensure JSON "[]" instead of "null".
|
||||
type eventEntry struct {
|
||||
Type string `json:"type"`
|
||||
Time string `json:"time"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
result := make([]eventEntry, 0, len(events))
|
||||
for _, e := range events {
|
||||
result = append(result, eventEntry{
|
||||
Type: e.Type,
|
||||
Time: e.Time,
|
||||
Data: e.Data,
|
||||
})
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"events": result,
|
||||
}); err != nil {
|
||||
log.Printf("[Mgmt] Failed to encode events: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMgmtSpotifyInit starts the Spotify OAuth flow by returning an authorization URL.
|
||||
func (s *Server) HandleMgmtSpotifyInit(w http.ResponseWriter, _ *http.Request) {
|
||||
s.mu.RLock()
|
||||
svc := s.spotifyService
|
||||
s.mu.RUnlock()
|
||||
|
||||
if svc == nil {
|
||||
http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
redirectURL := svc.BuildAuthorizeURL()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
|
||||
if err := enc.Encode(map[string]string{
|
||||
"redirectUrl": redirectURL,
|
||||
}); err != nil {
|
||||
log.Printf("[Mgmt] Failed to encode redirect URL: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMgmtSpotifyCallback is the browser OAuth callback from Spotify.
|
||||
// Not protected by Basic Auth — Spotify redirects the user's browser here directly.
|
||||
// Returns an HTML page the user can close.
|
||||
func (s *Server) HandleMgmtSpotifyCallback(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
svc := s.spotifyService
|
||||
s.mu.RUnlock()
|
||||
|
||||
if svc == nil {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(`<html><body><h1>Error</h1><p>Spotify integration not configured</p></body></html>`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if errMsg := r.URL.Query().Get("error"); errMsg != "" {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`<html><body><h1>Spotify Authorization Failed</h1><p>Error: ` + errMsg + `</p></body></html>`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`<html><body><h1>Missing authorization code</h1></body></html>`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err := svc.ExchangeCodeAndStore(code); err != nil {
|
||||
log.Printf("[Mgmt] Spotify callback failed: %v", err)
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`<html><body><h1>Error</h1><p>Token exchange failed</p></body></html>`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(`<html><body><h1>Spotify Connected</h1><p>You can close this window.</p></body></html>`))
|
||||
}
|
||||
|
||||
// HandleMgmtSpotifyConfirm exchanges an authorization code for tokens.
|
||||
// Used by the ueberboese mobile app after the deep link callback delivers the code.
|
||||
// Protected by Basic Auth.
|
||||
func (s *Server) HandleMgmtSpotifyConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
svc := s.spotifyService
|
||||
s.mu.RUnlock()
|
||||
|
||||
if svc == nil {
|
||||
http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
http.Error(w, `{"error":"missing code parameter"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := svc.ExchangeCodeAndStore(code); err != nil {
|
||||
log.Printf("[Mgmt] Spotify confirm failed: %v", err)
|
||||
http.Error(w, `{"error":"token exchange failed"}`, http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}
|
||||
|
||||
// HandleMgmtSpotifyAccounts returns linked Spotify accounts (tokens stripped).
|
||||
func (s *Server) HandleMgmtSpotifyAccounts(w http.ResponseWriter, _ *http.Request) {
|
||||
s.mu.RLock()
|
||||
svc := s.spotifyService
|
||||
s.mu.RUnlock()
|
||||
|
||||
if svc == nil {
|
||||
http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
accounts := svc.GetAccounts()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"accounts": accounts,
|
||||
}); err != nil {
|
||||
log.Printf("[Mgmt] Failed to encode accounts: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMgmtSpotifyToken returns a fresh Spotify access token and username.
|
||||
func (s *Server) HandleMgmtSpotifyToken(w http.ResponseWriter, _ *http.Request) {
|
||||
s.mu.RLock()
|
||||
svc := s.spotifyService
|
||||
s.mu.RUnlock()
|
||||
|
||||
if svc == nil {
|
||||
http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, username, err := svc.GetFreshToken()
|
||||
if err != nil {
|
||||
log.Printf("[Mgmt] Spotify token error: %v", err)
|
||||
http.Error(w, `{"error":"no token available"}`, http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"access_token": accessToken,
|
||||
"username": username,
|
||||
}); err != nil {
|
||||
log.Printf("[Mgmt] Failed to encode token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMgmtSpotifyEntity resolves a Spotify URI to name and image URL.
|
||||
func (s *Server) HandleMgmtSpotifyEntity(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
svc := s.spotifyService
|
||||
s.mu.RUnlock()
|
||||
|
||||
if svc == nil {
|
||||
http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to read body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var request struct {
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
if unmarshalErr := json.Unmarshal(body, &request); unmarshalErr != nil || request.URI == "" {
|
||||
http.Error(w, `{"error":"missing or invalid uri"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
name, imageURL, err := svc.ResolveEntity(request.URI)
|
||||
if err != nil {
|
||||
log.Printf("[Mgmt] Spotify entity resolve error: %v", err)
|
||||
http.Error(w, `{"error":"entity resolution failed"}`, http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"name": name,
|
||||
"imageUrl": imageURL,
|
||||
}); err != nil {
|
||||
log.Printf("[Mgmt] Failed to encode entity: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
@@ -33,33 +37,167 @@ func (s *Server) HandleProxyRequest(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
lp := proxy.NewLoggingProxy(target.String(), s.proxyRedact)
|
||||
lp.LogBody = s.proxyLogBody
|
||||
lp.RecordEnabled = s.recordEnabled
|
||||
lp.SetRecorder(s.recorder)
|
||||
s.ServeProxy(target)(w, r)
|
||||
}
|
||||
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
// Update director to set the correct host and path
|
||||
originalDirector := proxy.Director
|
||||
proxy.Director = func(req *http.Request) {
|
||||
originalDirector(req)
|
||||
req.Host = target.Host
|
||||
req.URL.Path = target.Path
|
||||
req.URL.RawQuery = r.URL.RawQuery
|
||||
lp.LogRequest(req)
|
||||
}
|
||||
// ServeProxy returns a handler that proxies to the given target.
|
||||
func (s *Server) ServeProxy(target *url.URL) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
lp := proxy.NewLoggingProxy(target.String(), s.proxyRedact)
|
||||
lp.LogBody = s.proxyLogBody
|
||||
lp.RecordEnabled = s.recordEnabled
|
||||
lp.SetRecorder(s.recorder)
|
||||
|
||||
proxy.ModifyResponse = func(res *http.Response) error {
|
||||
// Generic Header Preservation
|
||||
if etags, ok := res.Header["Etag"]; ok {
|
||||
delete(res.Header, "Etag")
|
||||
res.Header["ETag"] = etags
|
||||
// Capture request body for recording, as it will be consumed by the proxy
|
||||
var reqBody []byte
|
||||
if r.Body != nil {
|
||||
reqBody, _ = io.ReadAll(r.Body)
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
|
||||
}
|
||||
|
||||
lp.LogResponse(res)
|
||||
rp := httputil.NewSingleHostReverseProxy(target)
|
||||
rp.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
|
||||
return nil
|
||||
// Update director to set the correct host and path
|
||||
originalDirector := rp.Director
|
||||
rp.Director = func(req *http.Request) {
|
||||
originalDirector(req)
|
||||
req.Host = target.Host
|
||||
// If target has a path, we should probably append or replace.
|
||||
// For Bose upstream, it's usually just the domain.
|
||||
if target.Path != "" && target.Path != "/" {
|
||||
req.URL.Path = target.Path
|
||||
}
|
||||
|
||||
lp.LogRequest(req)
|
||||
}
|
||||
|
||||
rp.ModifyResponse = func(res *http.Response) error {
|
||||
res.Header.Set("X-Proxy-Origin", "upstream")
|
||||
// Generic Header Preservation
|
||||
if etags, ok := res.Header["Etag"]; ok {
|
||||
delete(res.Header, "Etag")
|
||||
res.Header["ETag"] = etags
|
||||
}
|
||||
|
||||
// Restore captured request body for the recorder
|
||||
if reqBody != nil {
|
||||
res.Request.Body = io.NopCloser(bytes.NewBuffer(reqBody))
|
||||
}
|
||||
|
||||
lp.LogResponse(res)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
rp.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleNotFound handles requests that don't match any route.
|
||||
func (s *Server) HandleNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
if s.enableSoundcorkProxy {
|
||||
s.HandleSoundcorkWithFallback(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
proxy.ServeHTTP(w, r)
|
||||
s.HandleBoseProxy(w, r)
|
||||
}
|
||||
|
||||
// HandleSoundcorkWithFallback tries Soundcork first, then Bose if Soundcork returns 404 or fails.
|
||||
func (s *Server) HandleSoundcorkWithFallback(w http.ResponseWriter, r *http.Request) {
|
||||
target, _ := url.Parse(s.soundcorkURL)
|
||||
|
||||
// Buffer request body if any, to allow multiple proxy attempts
|
||||
var bodyBytes []byte
|
||||
if r.Body != nil {
|
||||
bodyBytes, _ = io.ReadAll(r.Body)
|
||||
_ = r.Body.Close()
|
||||
}
|
||||
|
||||
// We use a custom response writer to catch 404s
|
||||
rw := &fallbackResponseWriter{
|
||||
ResponseWriter: w,
|
||||
statusCode: http.StatusOK,
|
||||
buffer: &bytes.Buffer{},
|
||||
}
|
||||
|
||||
// Create a shallow copy of the request to avoid side effects between attempts
|
||||
r2 := r.Clone(r.Context())
|
||||
if bodyBytes != nil {
|
||||
r2.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
} else {
|
||||
r2.Body = nil
|
||||
}
|
||||
|
||||
// Remove RequestURI as it's not allowed in client requests
|
||||
r2.RequestURI = ""
|
||||
|
||||
s.ServeProxy(target)(rw, r2)
|
||||
|
||||
if rw.statusCode == http.StatusNotFound || rw.statusCode == http.StatusBadGateway || rw.statusCode == http.StatusServiceUnavailable {
|
||||
log.Printf("[PROXY] Soundcork returned %d for %s, falling back to Bose", rw.statusCode, r.URL.Path)
|
||||
|
||||
if !rw.wroteHeader {
|
||||
// Restore original body if any
|
||||
if bodyBytes != nil {
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
}
|
||||
|
||||
s.HandleBoseProxy(w, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fallbackResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
wroteHeader bool
|
||||
buffer *bytes.Buffer
|
||||
}
|
||||
|
||||
func (rw *fallbackResponseWriter) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
if code != http.StatusNotFound && code != http.StatusBadGateway && code != http.StatusServiceUnavailable {
|
||||
rw.wroteHeader = true
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
}
|
||||
|
||||
func (rw *fallbackResponseWriter) Write(b []byte) (int, error) {
|
||||
if rw.statusCode == http.StatusNotFound || rw.statusCode == http.StatusBadGateway || rw.statusCode == http.StatusServiceUnavailable {
|
||||
return len(b), nil // Drop the body
|
||||
}
|
||||
|
||||
rw.wroteHeader = true
|
||||
|
||||
return rw.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// HandleBoseProxy proxies the request to the Bose upstream.
|
||||
func (s *Server) HandleBoseProxy(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.Host
|
||||
if host == "" {
|
||||
host = "streaming.bose.com"
|
||||
}
|
||||
|
||||
// Default to HTTPS for Bose services
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.0.0.1") || strings.HasPrefix(host, "::1") {
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
targetURL := scheme + "://" + host
|
||||
|
||||
target, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
log.Printf("[PROXY_ERR] Failed to parse target URL %s: %v", targetURL, err)
|
||||
http.Error(w, "Invalid upstream host", http.StatusBadGateway)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
s.ServeProxy(target)(w, r)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
)
|
||||
|
||||
func TestHandleProxyRequest_RequestBodyRecording(t *testing.T) {
|
||||
t.Setenv("RECORDER_ASYNC", "false")
|
||||
tmpDir, err := os.MkdirTemp("", "proxy-request-body-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Start a backend server to receive the proxied request
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Read the body to ensure it's consumed
|
||||
_, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("<response>ok</response>"))
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
|
||||
server := NewServer(ds, nil, "http://localhost:8000", false, false, false, false)
|
||||
server.recordEnabled = true
|
||||
server.proxyLogBody = true
|
||||
recorder := proxy.NewRecorder(tmpDir)
|
||||
server.SetRecorder(recorder)
|
||||
|
||||
// Create a proxy request to the backend
|
||||
requestBody := "<request>data</request>"
|
||||
targetURL := backend.URL
|
||||
proxyPath := "/proxy/" + targetURL
|
||||
req := httptest.NewRequest("POST", proxyPath, bytes.NewBufferString(requestBody))
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.HandleProxyRequest(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Verify that the interaction was recorded and contains the request body
|
||||
sessionID := recorder.SessionID
|
||||
|
||||
// The recorder uses sanitized segments for the directory.
|
||||
// Since the target URL is http://127.0.0.1:PORT, the path is empty,
|
||||
// so it should be in the "root" directory under the category.
|
||||
|
||||
// We'll search recursively to be sure
|
||||
foundBody := false
|
||||
err = filepath.Walk(filepath.Join(tmpDir, "interactions", sessionID), func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() && strings.HasSuffix(path, ".http") {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(string(content), requestBody) {
|
||||
foundBody = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("failed to walk interactions dir: %v", err)
|
||||
}
|
||||
|
||||
if !foundBody {
|
||||
t.Errorf("request body %q not found in any recorded interaction file", requestBody)
|
||||
// List all files found for debugging
|
||||
_ = filepath.Walk(filepath.Join(tmpDir, "interactions", sessionID), func(path string, info os.FileInfo, err error) error {
|
||||
if !info.IsDir() {
|
||||
content, _ := os.ReadFile(path)
|
||||
t.Logf("Found file %s with content:\n%s", path, string(content))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,18 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"fmt"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -61,6 +69,7 @@ func (s *Server) HandleAddManualDevice(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
s.handleDiscoveredDevice(d)
|
||||
s.mergeOverlappingDevices()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -89,36 +98,181 @@ func (s *Server) HandleGetDiscoveryStatus(w http.ResponseWriter, _ *http.Request
|
||||
}
|
||||
}
|
||||
|
||||
// HandleRemoveDevice removes a device from the datastore.
|
||||
func (s *Server) HandleRemoveDevice(w http.ResponseWriter, r *http.Request) {
|
||||
deviceId := chi.URLParam(r, "deviceId")
|
||||
if deviceId == "" {
|
||||
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Find which account this device belongs to.
|
||||
devices, err := s.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var found bool
|
||||
|
||||
for i := range devices {
|
||||
if devices[i].DeviceID == deviceId {
|
||||
err = s.ds.RemoveDevice(devices[i].AccountID, devices[i].DeviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
found = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
http.Error(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]bool{"ok": true}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGetSettings returns the current service settings.
|
||||
func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"server_url": s.serverURL,
|
||||
"proxy_url": s.proxyURL,
|
||||
s.mu.RLock()
|
||||
serverURL, soundcorkURL, httpsServerURL := s.serverURL, s.soundcorkURL, s.httpsServerURL
|
||||
discoveryInterval := s.discoveryInterval.String()
|
||||
discoveryEnabled := s.discoveryEnabled
|
||||
dnsEnabled := s.dnsEnabled
|
||||
dnsUpstream := s.dnsUpstream
|
||||
dnsBindAddr := s.dnsBindAddr
|
||||
enableSoundcorkProxy := s.enableSoundcorkProxy
|
||||
redact, logBody, record := s.proxyRedact, s.proxyLogBody, s.recordEnabled
|
||||
shortcuts := s.shortcuts
|
||||
s.mu.RUnlock()
|
||||
|
||||
dnsRunning, actualBind := s.GetDNSRunning()
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"server_url": serverURL,
|
||||
"soundcork_url": soundcorkURL,
|
||||
"https_server_url": httpsServerURL,
|
||||
"discovery_interval": discoveryInterval,
|
||||
"discovery_enabled": discoveryEnabled,
|
||||
"dns_enabled": dnsEnabled,
|
||||
"dns_running": dnsRunning,
|
||||
"dns_actual_bind": actualBind,
|
||||
"dns_upstream": dnsUpstream,
|
||||
"dns_bind_addr": dnsBindAddr,
|
||||
"enable_soundcork_proxy": enableSoundcorkProxy,
|
||||
"redact_logs": redact,
|
||||
"log_bodies": logBody,
|
||||
"record_interactions": record,
|
||||
"shortcuts": shortcuts,
|
||||
}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGetDeviceInfo returns live information for a device.
|
||||
func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
http.Error(w, "Device IP is required", http.StatusBadRequest)
|
||||
// HandleUpdateSettings updates the service settings.
|
||||
func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var settings struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
SoundcorkURL string `json:"soundcork_url"`
|
||||
DiscoveryInterval string `json:"discovery_interval"`
|
||||
DiscoveryEnabled bool `json:"discovery_enabled"`
|
||||
DNSEnabled bool `json:"dns_enabled"`
|
||||
DNSUpstream string `json:"dns_upstream"`
|
||||
DNSBindAddr string `json:"dns_bind_addr"`
|
||||
EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"`
|
||||
Shortcuts map[string]int `json:"shortcuts"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := s.sm.GetLiveDeviceInfo(deviceIP)
|
||||
if settings.DNSEnabled && settings.DNSUpstream == "" {
|
||||
http.Error(w, "DNS Upstream is required when DNS Discovery is enabled", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
interval, err := time.ParseDuration(settings.DiscoveryInterval)
|
||||
if err != nil && settings.DiscoveryInterval != "" {
|
||||
http.Error(w, "Invalid discovery interval: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.serverURL = settings.ServerURL
|
||||
|
||||
s.soundcorkURL = settings.SoundcorkURL
|
||||
if settings.DiscoveryInterval != "" {
|
||||
s.discoveryInterval = interval
|
||||
}
|
||||
|
||||
s.discoveryEnabled = settings.DiscoveryEnabled
|
||||
s.dnsEnabled = settings.DNSEnabled
|
||||
s.dnsUpstream = settings.DNSUpstream
|
||||
s.dnsBindAddr = settings.DNSBindAddr
|
||||
|
||||
s.enableSoundcorkProxy = settings.EnableSoundcorkProxy
|
||||
if settings.Shortcuts != nil {
|
||||
s.shortcuts = settings.Shortcuts
|
||||
}
|
||||
|
||||
if s.sm != nil {
|
||||
s.sm.ServerURL = settings.ServerURL
|
||||
}
|
||||
|
||||
// Persist to datastore
|
||||
// Access fields directly since we already hold the lock
|
||||
currentRedact := s.proxyRedact
|
||||
currentLogBody := s.proxyLogBody
|
||||
currentRecord := s.recordEnabled
|
||||
currentHTTPS := s.httpsServerURL
|
||||
|
||||
log.Printf("Saving updated settings to %s/settings.json", s.ds.DataDir)
|
||||
err = s.ds.SaveSettings(datastore.Settings{
|
||||
ServerURL: s.serverURL,
|
||||
SoundcorkURL: s.soundcorkURL,
|
||||
HTTPServerURL: currentHTTPS,
|
||||
RedactLogs: currentRedact,
|
||||
LogBodies: currentLogBody,
|
||||
RecordInteractions: currentRecord,
|
||||
DiscoveryInterval: s.discoveryInterval.String(),
|
||||
DiscoveryEnabled: s.discoveryEnabled,
|
||||
DNSEnabled: s.dnsEnabled,
|
||||
DNSUpstream: s.dnsUpstream,
|
||||
DNSBindAddr: s.dnsBindAddr,
|
||||
EnableSoundcorkProxy: s.enableSoundcorkProxy,
|
||||
Shortcuts: s.shortcuts,
|
||||
})
|
||||
|
||||
dnsEnabled := s.dnsEnabled
|
||||
dnsUpstream := s.dnsUpstream
|
||||
dnsBindAddr := s.dnsBindAddr
|
||||
|
||||
s.mu.Unlock()
|
||||
|
||||
s.SetDNSSettings(dnsEnabled, dnsUpstream, dnsBindAddr)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
http.Error(w, "Failed to save settings: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(info); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Settings updated"}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -126,9 +280,15 @@ func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleGetMigrationSummary returns a summary of the migration plan for a device.
|
||||
func (s *Server) HandleGetMigrationSummary(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
http.Error(w, "Device IP is required", http.StatusBadRequest)
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -159,12 +319,12 @@ func (s *Server) HandleGetMigrationSummary(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
// HandleMigrateDevice starts the migration process for a device.
|
||||
func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -172,6 +332,12 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
targetURL := r.URL.Query().Get("target_url")
|
||||
proxyURL := r.URL.Query().Get("proxy_url")
|
||||
method := setup.MigrationMethod(r.URL.Query().Get("method"))
|
||||
@@ -207,12 +373,12 @@ func (s *Server) HandleMigrateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleRevertMigration reverts the migration for a device.
|
||||
func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -220,6 +386,12 @@ func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
output, err := s.sm.RevertMigration(deviceIP)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -241,14 +413,93 @@ func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGetDNSDiscoveries returns recorded DNS discoveries.
|
||||
func (s *Server) HandleGetDNSDiscoveries(w http.ResponseWriter, _ *http.Request) {
|
||||
// 1. Get current in-memory discoveries
|
||||
inMemory := s.GetDNSDiscovery()
|
||||
|
||||
// 2. Load persisted discoveries
|
||||
persisted, err := s.ds.LoadDNSDiscoveries()
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to load DNS discoveries: %v", err)
|
||||
}
|
||||
|
||||
// 3. Merge them
|
||||
merged := make(map[string]datastore.DNSDiscoveryEntry)
|
||||
for _, p := range persisted {
|
||||
merged[p.Hostname] = p
|
||||
}
|
||||
|
||||
for hostname, h := range inMemory {
|
||||
m, exists := merged[hostname]
|
||||
if !exists || h.LastSeen.After(m.LastSeen) {
|
||||
merged[hostname] = datastore.DNSDiscoveryEntry{
|
||||
Hostname: h.Hostname,
|
||||
FirstSeen: h.FirstSeen,
|
||||
LastSeen: h.LastSeen,
|
||||
QueryCount: h.QueryCount,
|
||||
IsBoseService: h.IsBoseService,
|
||||
IsIntercepted: h.IsIntercepted,
|
||||
RemoteAddr: h.RemoteAddr,
|
||||
}
|
||||
} else if h.QueryCount > m.QueryCount {
|
||||
// If exists and persisted is newer (rare but possible), update query count if higher
|
||||
m.QueryCount = h.QueryCount
|
||||
merged[hostname] = m
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to slice
|
||||
result := make([]datastore.DNSDiscoveryEntry, 0, len(merged))
|
||||
for _, entry := range merged {
|
||||
result = append(result, entry)
|
||||
}
|
||||
|
||||
// Sort by last seen descending
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].LastSeen.After(result[j].LastSeen)
|
||||
})
|
||||
|
||||
// 4. Update persistence with merged results
|
||||
if err := s.ds.SaveDNSDiscoveries(result); err != nil {
|
||||
log.Printf("Warning: Failed to persist merged DNS discoveries: %v", err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(result); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleClearDNSDiscoveries clears recorded DNS discoveries.
|
||||
func (s *Server) HandleClearDNSDiscoveries(w http.ResponseWriter, _ *http.Request) {
|
||||
// 1. Clear in-memory
|
||||
s.SetDNSDiscoveries(make(map[string]*discovery.DiscoveredHost))
|
||||
|
||||
// 2. Clear persistence
|
||||
if err := s.ds.ClearDNSDiscoveries(); err != nil {
|
||||
http.Error(w, "Failed to clear DNS discoveries: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]bool{"ok": true}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTrustCACert injects the local Root CA into the device's shared trust store.
|
||||
func (s *Server) HandleTrustCACert(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -256,6 +507,12 @@ func (s *Server) HandleTrustCACert(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
output, err := s.sm.TrustCACert(deviceIP)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -279,12 +536,12 @@ func (s *Server) HandleTrustCACert(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleEnsureRemoteServices ensures that remote services are configured on a device.
|
||||
func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -292,6 +549,12 @@ func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
output, err := s.sm.EnsureRemoteServices(deviceIP)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -315,12 +578,12 @@ func (s *Server) HandleEnsureRemoteServices(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
// HandleRemoveRemoteServices removes remote services configuration from a device.
|
||||
func (s *Server) HandleRemoveRemoteServices(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -328,6 +591,12 @@ func (s *Server) HandleRemoveRemoteServices(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
output, err := s.sm.RemoveRemoteServices(deviceIP)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -351,12 +620,12 @@ func (s *Server) HandleRemoveRemoteServices(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
// HandleBackupConfig creates a backup of the device configuration.
|
||||
func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -364,6 +633,12 @@ func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
output, err := s.sm.BackupConfig(deviceIP)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -389,10 +664,13 @@ func (s *Server) HandleBackupConfig(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) HandleGetProxySettings(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]bool{
|
||||
"redact": s.proxyRedact,
|
||||
"log_body": s.proxyLogBody,
|
||||
"record": s.recordEnabled,
|
||||
redact, logBody, record, enableSoundcorkProxy := s.GetProxySettings()
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"redact": redact,
|
||||
"log_body": logBody,
|
||||
"record": record,
|
||||
"enable_soundcork_proxy": enableSoundcorkProxy,
|
||||
}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -417,18 +695,51 @@ func (s *Server) HandleGetCACert(w http.ResponseWriter, _ *http.Request) {
|
||||
// HandleUpdateProxySettings updates the proxy settings.
|
||||
func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Request) {
|
||||
var settings struct {
|
||||
Redact bool `json:"redact"`
|
||||
LogBody bool `json:"log_body"`
|
||||
Record bool `json:"record"`
|
||||
Redact bool `json:"redact"`
|
||||
LogBody bool `json:"log_body"`
|
||||
Record bool `json:"record"`
|
||||
EnableSoundcorkProxy bool `json:"enable_soundcork_proxy"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.proxyRedact = settings.Redact
|
||||
s.proxyLogBody = settings.LogBody
|
||||
s.recordEnabled = settings.Record
|
||||
s.enableSoundcorkProxy = settings.EnableSoundcorkProxy
|
||||
|
||||
if s.recorder != nil {
|
||||
s.recorder.Redact = settings.Redact
|
||||
}
|
||||
|
||||
// Persist to datastore
|
||||
// Access fields directly since we already hold the lock
|
||||
serverURL, soundcorkURL, httpsServerURL := s.serverURL, s.soundcorkURL, s.httpsServerURL
|
||||
discoveryInterval := s.discoveryInterval.String()
|
||||
discoveryEnabled := s.discoveryEnabled
|
||||
|
||||
log.Printf("Saving updated proxy settings to %s/settings.json", s.ds.DataDir)
|
||||
err := s.ds.SaveSettings(datastore.Settings{
|
||||
ServerURL: serverURL,
|
||||
SoundcorkURL: soundcorkURL,
|
||||
HTTPServerURL: httpsServerURL,
|
||||
RedactLogs: s.proxyRedact,
|
||||
LogBodies: s.proxyLogBody,
|
||||
RecordInteractions: s.recordEnabled,
|
||||
DiscoveryInterval: discoveryInterval,
|
||||
DiscoveryEnabled: discoveryEnabled,
|
||||
EnableSoundcorkProxy: s.enableSoundcorkProxy,
|
||||
Shortcuts: s.shortcuts,
|
||||
})
|
||||
s.mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to save settings: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -440,9 +751,15 @@ func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
// HandleTestHostsRedirection performs a preliminary check for /etc/hosts redirection.
|
||||
func (s *Server) HandleTestHostsRedirection(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
http.Error(w, "Device IP is required", http.StatusBadRequest)
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -478,11 +795,63 @@ func (s *Server) HandleTestHostsRedirection(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTestDNSRedirection performs a check for DNS redirection to the AfterTouch service.
|
||||
func (s *Server) HandleTestDNSRedirection(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
targetURL := r.URL.Query().Get("target_url")
|
||||
if targetURL == "" {
|
||||
targetURL = s.serverURL
|
||||
}
|
||||
|
||||
output, err := s.sm.TestDNSRedirection(deviceIP, targetURL)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK) // Return 200 but ok: false so UI can show the output
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"message": err.Error(),
|
||||
"output": output,
|
||||
}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
"message": "DNS redirection test successful",
|
||||
"output": output,
|
||||
}); encodeErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleInitialSync fetches presets, recents and sources from the device and saves them to the datastore.
|
||||
func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
http.Error(w, "Missing deviceIP", http.StatusBadRequest)
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -497,12 +866,12 @@ func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleRebootDevice reboots a device.
|
||||
func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device IP is required"}); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "Device ID is required"}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -510,6 +879,12 @@ func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
output, err := s.sm.Reboot(deviceIP)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -533,9 +908,15 @@ func (s *Server) HandleRebootDevice(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleTestConnection performs a connection check from the device to the server.
|
||||
func (s *Server) HandleTestConnection(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
if deviceIP == "" {
|
||||
http.Error(w, "Device IP is required", http.StatusBadRequest)
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -573,3 +954,160 @@ func (s *Server) HandleTestConnection(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGetVersionInfo returns version information for the service.
|
||||
func (s *Server) HandleGetVersionInfo(w http.ResponseWriter, _ *http.Request) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"version": s.Version,
|
||||
"commit": s.Commit,
|
||||
"date": s.Date,
|
||||
}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGetInteractionStats returns statistics about recorded interactions.
|
||||
func (s *Server) HandleGetInteractionStats(w http.ResponseWriter, _ *http.Request) {
|
||||
if s.recorder == nil {
|
||||
http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := s.recorder.GetInteractionStats()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(stats); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleListInteractions returns a list of recorded interactions.
|
||||
func (s *Server) HandleListInteractions(w http.ResponseWriter, r *http.Request) {
|
||||
if s.recorder == nil {
|
||||
http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
session := r.URL.Query().Get("session")
|
||||
category := r.URL.Query().Get("category")
|
||||
since := r.URL.Query().Get("since")
|
||||
|
||||
interactions, err := s.recorder.ListInteractions(session, category, since)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(interactions); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGetInteractionContent returns the raw content of a recorded interaction.
|
||||
func (s *Server) HandleGetInteractionContent(w http.ResponseWriter, r *http.Request) {
|
||||
if s.recorder == nil {
|
||||
http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
file := r.URL.Query().Get("file")
|
||||
if file == "" {
|
||||
http.Error(w, "File parameter is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
content, err := s.recorder.GetInteractionContent(file)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write(content)
|
||||
}
|
||||
|
||||
// HandleDeleteSession deletes a recorded interaction session.
|
||||
func (s *Server) HandleDeleteSession(w http.ResponseWriter, r *http.Request) {
|
||||
if s.recorder == nil {
|
||||
http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
session := chi.URLParam(r, "session")
|
||||
if session == "" {
|
||||
http.Error(w, "Session ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.recorder.DeleteSession(session); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok": true}`))
|
||||
}
|
||||
|
||||
// HandleCleanupSessions deletes all but the most recent N sessions.
|
||||
func (s *Server) HandleCleanupSessions(w http.ResponseWriter, r *http.Request) {
|
||||
if s.recorder == nil {
|
||||
http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
keep := 10
|
||||
|
||||
keepStr := r.URL.Query().Get("keep")
|
||||
if keepStr != "" {
|
||||
if k, err := strconv.Atoi(keepStr); err == nil {
|
||||
keep = k
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.recorder.CleanupSessions(keep); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok": true}`))
|
||||
}
|
||||
|
||||
// HandleDownloadSession returns a .tar.gz archive of a recorded interaction session.
|
||||
func (s *Server) HandleDownloadSession(w http.ResponseWriter, r *http.Request) {
|
||||
if s.recorder == nil {
|
||||
http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
session := chi.URLParam(r, "session")
|
||||
if session == "" {
|
||||
http.Error(w, "Session ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.tar.gz\"", session))
|
||||
|
||||
if err := s.recorder.ArchiveSession(session, w); err != nil {
|
||||
log.Printf("Error archiving session %s: %v", session, err)
|
||||
// Since we already set headers, if we have an error here it might be partially written.
|
||||
// But for now, simple error handling.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,26 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
)
|
||||
|
||||
func TestProxySettingsAPI(t *testing.T) {
|
||||
r, server := setupRouter("http://localhost:8001", nil)
|
||||
tempDir, err := os.MkdirTemp("", "proxy-settings-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
r, server := setupRouter("http://localhost:8001", ds)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
@@ -86,6 +97,34 @@ func TestProxySettingsAPI(t *testing.T) {
|
||||
if settings["redact"] != false || settings["log_body"] != true {
|
||||
t.Errorf("GET (after update): Unexpected settings: %+v", settings)
|
||||
}
|
||||
|
||||
// 3. Test System Settings POST
|
||||
sysUpdate := map[string]string{
|
||||
"server_url": "http://new-server:8000",
|
||||
"soundcork_url": "http://new-proxy:8001",
|
||||
}
|
||||
|
||||
sysBody, err := json.Marshal(sysUpdate)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal system settings data: %v", err)
|
||||
}
|
||||
|
||||
res, err = http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(sysBody))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("POST /setup/settings: Expected status OK, got %v", res.Status)
|
||||
}
|
||||
|
||||
// Verify server state
|
||||
sURL, pURL, _ := server.GetSettings()
|
||||
if sURL != "http://new-server:8000" || pURL != "http://new-proxy:8001" {
|
||||
t.Errorf("POST /setup/settings: Server state did not update: serverURL=%s, soundcorkURL=%s", sURL, pURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationAndCA(t *testing.T) {
|
||||
@@ -103,9 +142,15 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
sm := setup.NewManager("http://localhost:8000", ds, cm)
|
||||
// Mock SSH to avoid real connections
|
||||
sm.NewSSH = func(host string) setup.SSHClient {
|
||||
return &mockSSH{}
|
||||
return &mockSSH{host: host}
|
||||
}
|
||||
|
||||
// Register device in datastore so lookupIP works
|
||||
_ = ds.SaveDeviceInfo("default", "192.168.1.10", &models.ServiceDeviceInfo{
|
||||
DeviceID: "192.168.1.10",
|
||||
IPAddress: "192.168.1.10",
|
||||
})
|
||||
|
||||
r, server := setupRouter("http://localhost:8001", ds)
|
||||
server.sm = sm // Inject our manager with mock SSH
|
||||
|
||||
@@ -126,8 +171,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("CA: Unexpected content type: %s", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// 2. Test POST /setup/migrate/{deviceIP}?method=hosts
|
||||
res, err = http.Post(ts.URL+"/setup/migrate/192.168.1.10?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
|
||||
// 2. Test POST /setup/devices/{deviceIP}/migrate?method=hosts
|
||||
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/migrate?method=hosts&target_url=http://192.168.1.100:8000", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -148,8 +193,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("Migrate: Expected output field in response")
|
||||
}
|
||||
|
||||
// 3. Test POST /setup/trust-ca/{deviceIP}
|
||||
res, err = http.Post(ts.URL+"/setup/trust-ca/192.168.1.10", "application/json", nil)
|
||||
// 3. Test POST /setup/devices/{deviceIP}/trust-ca
|
||||
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/trust-ca", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -169,8 +214,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("TrustCA: Expected output field in response")
|
||||
}
|
||||
|
||||
// 4. Test POST /setup/reboot/{deviceIP}
|
||||
res, err = http.Post(ts.URL+"/setup/reboot/192.168.1.10", "application/json", nil)
|
||||
// 4. Test POST /devices/{deviceIP}/reboot
|
||||
res, err = http.Post(ts.URL+"/devices/192.168.1.10/reboot", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -190,8 +235,8 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
t.Errorf("Reboot: Expected output field in response")
|
||||
}
|
||||
|
||||
// 5. Test POST /setup/remove-remote-services/{deviceIP}
|
||||
res, err = http.Post(ts.URL+"/setup/remove-remote-services/192.168.1.10", "application/json", nil)
|
||||
// 5. Test POST /setup/devices/{deviceIP}/remove-remote-services
|
||||
res, err = http.Post(ts.URL+"/setup/devices/192.168.1.10/remove-remote-services", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -212,12 +257,119 @@ func TestMigrationAndCA(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type mockSSH struct{}
|
||||
func TestRemoveDevice(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "remove-device-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
// Setup a dummy device in the datastore
|
||||
account := "acc1"
|
||||
deviceID := "TEST-DEVICE-ID"
|
||||
|
||||
// Register device in datastore so HandleRemoveDevice works
|
||||
_ = ds.SaveDeviceInfo(account, deviceID, &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: account,
|
||||
IPAddress: "192.168.1.100",
|
||||
})
|
||||
|
||||
// Verify directory exists where datastore expects it
|
||||
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
|
||||
if _, err := os.Stat(deviceDir); err != nil {
|
||||
t.Fatalf("Device directory was not created by SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
r, _ := setupRouter("http://localhost:8001", ds)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// 1. Verify device exists
|
||||
res, err := http.Get(ts.URL + "/devices")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var devices []map[string]interface{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&devices); err != nil {
|
||||
t.Fatalf("Failed to decode devices: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, d := range devices {
|
||||
if d["device_id"] == deviceID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("Device not found in list before removal")
|
||||
}
|
||||
|
||||
// 2. Remove device
|
||||
req, err := http.NewRequest(http.MethodDelete, ts.URL+"/devices/"+deviceID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %v", res.Status)
|
||||
}
|
||||
|
||||
// 3. Verify device is gone
|
||||
res, err = http.Get(ts.URL + "/devices")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if err := json.NewDecoder(res.Body).Decode(&devices); err != nil {
|
||||
t.Fatalf("Failed to decode devices after removal: %v", err)
|
||||
}
|
||||
|
||||
for _, d := range devices {
|
||||
if d["device_id"] == deviceID {
|
||||
t.Errorf("Device still exists in list after removal")
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Verify directory is gone
|
||||
if _, err := os.Stat(deviceDir); !os.IsNotExist(err) {
|
||||
t.Errorf("Device directory still exists after removal")
|
||||
}
|
||||
}
|
||||
|
||||
type mockSSH struct {
|
||||
host string
|
||||
runCount int
|
||||
}
|
||||
|
||||
func (m *mockSSH) Run(command string) (string, error) {
|
||||
if command == "cat /etc/hosts" {
|
||||
if strings.Contains(command, "cat /etc/hosts") {
|
||||
m.runCount++
|
||||
if m.runCount > 1 {
|
||||
// Return updated hosts for verification
|
||||
return "127.0.0.1 localhost\n192.168.1.100\tstreaming.bose.com\n192.168.1.100\tupdates.bose.com\n192.168.1.100\tstats.bose.com\n192.168.1.100\tbmx.bose.com\n192.168.1.100\tcontent.api.bose.io\n192.168.1.100\tevents.api.bosecm.com\n192.168.1.100\tbose-prod.apigee.net\n192.168.1.100\tworldwide.bose.com", nil
|
||||
}
|
||||
return "127.0.0.1 localhost", nil
|
||||
}
|
||||
if strings.HasPrefix(command, "[ -f") {
|
||||
return "", nil // Pretend file exists for backups
|
||||
}
|
||||
if strings.HasPrefix(command, "grep -F") {
|
||||
return "matched", nil // CA trusted
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *mockSSH) UploadContent(content []byte, remotePath string) error { return nil }
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// HandleUsageStats handles Marge usage stats uploads.
|
||||
@@ -49,6 +50,42 @@ func (s *Server) HandleUsageStats(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// HandleAppEvents handles events from the Bose SoundTouch app (stapp/scmudc).
|
||||
func (s *Server) HandleAppEvents(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req models.DeviceEventsRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, "Invalid app events format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceID := req.Envelope.UniqueID
|
||||
if deviceID == "" {
|
||||
deviceID = chi.URLParam(r, "deviceId")
|
||||
}
|
||||
|
||||
for _, e := range req.Payload.Events {
|
||||
event := models.DeviceEvent{
|
||||
Type: e.Type,
|
||||
Time: e.Time,
|
||||
MonoTime: req.Envelope.MonoTime,
|
||||
Data: e.Data,
|
||||
}
|
||||
if event.Time == "" {
|
||||
event.Time = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
s.ds.AddDeviceEvent(deviceID, event)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// HandleErrorStats handles Marge error stats uploads.
|
||||
func (s *Server) HandleErrorStats(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
func TestStatsHandlers(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "soundcork-test-*")
|
||||
tempDir, err := os.MkdirTemp("", "st-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -63,4 +63,44 @@ func TestStatsHandlers(t *testing.T) {
|
||||
t.Error("Error stats file was not created")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HandleAppEvents", func(t *testing.T) {
|
||||
jsonData := `{
|
||||
"envelope": {
|
||||
"monoTime": 12345,
|
||||
"payloadProtocolVersion": "3.1",
|
||||
"payloadType": "stapp",
|
||||
"protocolVersion": "1.0",
|
||||
"time": "2023-10-27T10:00:00Z",
|
||||
"uniqueId": "device789"
|
||||
},
|
||||
"payload": {
|
||||
"deviceInfo": {
|
||||
"deviceID": "device789"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"type": "APP_OPEN",
|
||||
"time": "2023-10-27T10:00:01Z",
|
||||
"data": {"foo": "bar"}
|
||||
}
|
||||
]
|
||||
}
|
||||
}`
|
||||
req := httptest.NewRequest("POST", "/v1/stapp/device789", bytes.NewBufferString(jsonData))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.HandleAppEvents(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status OK, got %d", w.Code)
|
||||
}
|
||||
|
||||
events := ds.GetDeviceEvents("device789")
|
||||
if len(events) == 0 {
|
||||
t.Error("App events were not recorded")
|
||||
} else if events[0].Type != "APP_OPEN" {
|
||||
t.Errorf("Expected event type APP_OPEN, got %s", events[0].Type)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// HandleGetDeviceInfo returns live information for a device.
|
||||
func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := s.sm.GetLiveDeviceInfo(deviceIP)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Include IP address in both snake_case and camelCase for frontend compatibility
|
||||
type deviceInfoResponse struct {
|
||||
*setup.DeviceInfoXML `json:",inline"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
IPAddressCamel string `json:"ipAddress,omitempty"`
|
||||
}
|
||||
|
||||
resp := deviceInfoResponse{
|
||||
DeviceInfoXML: info,
|
||||
IPAddress: deviceIP,
|
||||
IPAddressCamel: deviceIP,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeviceKey sends a key command to a device.
|
||||
func (s *Server) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
|
||||
key := chi.URLParam(r, "key")
|
||||
if deviceID == "" || key == "" {
|
||||
http.Error(w, "Device ID and Key are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
|
||||
err = c.SendKey(key)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to send key %s to %s: %v", key, deviceIP, err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Key sent"}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeviceVolume sets the volume level for a device.
|
||||
func (s *Server) HandleDeviceVolume(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
|
||||
levelStr := chi.URLParam(r, "level")
|
||||
if deviceID == "" || levelStr == "" {
|
||||
http.Error(w, "Device ID and Level are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
level, err := strconv.Atoi(levelStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid volume level", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
|
||||
err = c.SetVolume(level)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to set volume to %d on %s: %v", level, deviceIP, err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Volume set"}); err != nil {
|
||||
log.Printf("Failed to encode JSON response: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(_ *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
const (
|
||||
pongWait = 40 * time.Second
|
||||
pingPeriod = 20 * time.Second // must be less than pongWait
|
||||
)
|
||||
|
||||
// HandleDeviceWebSocket upgrades the connection and proxies device WebSocket events to the browser.
|
||||
func (s *Server) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deviceIP, err := s.lookupIP(deviceID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Upgrade the HTTP connection to a WebSocket for the browser
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Create a SoundTouch WebSocket client for the target device
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
wsClient := c.NewWebSocketClient(client.DefaultWebSocketConfig())
|
||||
|
||||
// Channel-based write pump per Gorilla best practices
|
||||
sendCh := make(chan []byte, 64) // buffer to smooth bursts
|
||||
closeCh := make(chan struct{})
|
||||
|
||||
// Helper to enqueue JSON messages; drop if buffer is full to avoid blocking
|
||||
enqueue := func(v interface{}) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case sendCh <- b:
|
||||
default:
|
||||
// drop to protect connection under burst
|
||||
}
|
||||
}
|
||||
|
||||
// Reader: we don't expect messages from the browser; just keep the
|
||||
// connection alive by processing control frames and detect close.
|
||||
_ = conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
return conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
})
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
close(closeCh)
|
||||
|
||||
_ = wsClient.Disconnect()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
mt, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
log.Printf("[WebSocket] Browser connection closed for %s: %v", deviceIP, err)
|
||||
return
|
||||
}
|
||||
|
||||
if mt == websocket.CloseMessage {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Writer: single writer goroutine handles JSON writes and ping keepalive
|
||||
go func() {
|
||||
pingTicker := time.NewTicker(pingPeriod)
|
||||
|
||||
defer func() {
|
||||
pingTicker.Stop()
|
||||
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-sendCh:
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if !ok {
|
||||
_ = conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
|
||||
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
return
|
||||
}
|
||||
case <-pingTicker.C:
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
case <-closeCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Forward typed events with a simple envelope into the send queue
|
||||
wsClient.SetHandlers(&models.WebSocketEventHandlers{
|
||||
OnNowPlaying: func(e *models.NowPlayingUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "nowPlayingUpdated", "payload": e})
|
||||
},
|
||||
OnVolumeUpdated: func(e *models.VolumeUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "volumeUpdated", "payload": e})
|
||||
},
|
||||
OnConnectionState: func(e *models.ConnectionStateUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "connectionStateUpdated", "payload": e})
|
||||
},
|
||||
OnPresetUpdated: func(e *models.PresetUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "presetUpdated", "payload": e})
|
||||
},
|
||||
OnZoneUpdated: func(e *models.ZoneUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "zoneUpdated", "payload": e})
|
||||
},
|
||||
OnBassUpdated: func(e *models.BassUpdatedEvent) {
|
||||
enqueue(map[string]interface{}{"type": "bassUpdated", "payload": e})
|
||||
},
|
||||
OnUnknownEvent: func(event *models.WebSocketEvent) {
|
||||
bytes, _ := json.Marshal(event)
|
||||
enqueue(map[string]interface{}{"type": "unknown", "payload": json.RawMessage(bytes)})
|
||||
},
|
||||
OnSpecialMessage: func(msg *models.SpecialMessage) {
|
||||
enqueue(map[string]interface{}{"type": "special", "payload": msg})
|
||||
},
|
||||
})
|
||||
|
||||
// Add a separate goroutine to monitor the device connection status
|
||||
go func() {
|
||||
wsClient.Wait()
|
||||
log.Printf("[WebSocket] Device %s client terminated", deviceIP)
|
||||
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
// Connect to the device WebSocket
|
||||
if err := wsClient.Connect(); err != nil {
|
||||
enqueue(map[string]interface{}{"type": "error", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Optional: send an initial snapshot for convenience
|
||||
go func() {
|
||||
info, err := s.sm.GetLiveDeviceInfo(deviceIP)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Supplement with volume and now playing
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
payload := map[string]interface{}{
|
||||
"deviceID": info.DeviceID,
|
||||
"name": info.Name,
|
||||
"type": info.Type,
|
||||
"maccAddress": info.MaccAddress,
|
||||
"serialNumber": info.SerialNumber,
|
||||
"softwareVersion": info.SoftwareVer,
|
||||
// Provide IP in both styles for frontend robustness
|
||||
"ip_address": deviceIP,
|
||||
"ipAddress": deviceIP,
|
||||
}
|
||||
|
||||
if vol, err := c.GetVolume(); err == nil {
|
||||
payload["volume"] = vol
|
||||
// Also add at top level for flatter frontend parsing
|
||||
payload["actualVolume"] = vol.ActualVolume
|
||||
}
|
||||
|
||||
if np, err := c.GetNowPlaying(); err == nil {
|
||||
payload["nowPlaying"] = np
|
||||
}
|
||||
|
||||
enqueue(map[string]interface{}{"type": "snapshotInfo", "payload": payload})
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestInteractionHandlers(t *testing.T) {
|
||||
t.Setenv("RECORDER_ASYNC", "false")
|
||||
tmpDir, err := os.MkdirTemp("", "interaction-handlers-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
|
||||
server := &Server{ds: ds}
|
||||
|
||||
t.Run("HandleGetInteractionStats_NoRecorder", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/setup/interaction-stats", nil)
|
||||
w := httptest.NewRecorder()
|
||||
server.HandleGetInteractionStats(w, req)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("Expected status 503, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
recorder := proxy.NewRecorder(tmpDir)
|
||||
server.SetRecorder(recorder)
|
||||
|
||||
// Create a dummy interaction file
|
||||
sessionID := recorder.SessionID
|
||||
relPath := filepath.Join(sessionID, "self", "test", "0001-12-00-00.000-GET.http")
|
||||
fullPath := filepath.Join(tmpDir, "interactions", relPath)
|
||||
os.MkdirAll(filepath.Dir(fullPath), 0755)
|
||||
os.WriteFile(fullPath, []byte("### GET /test\n\n> {% \n // Response: 200 OK\n%}\n"), 0644)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
r.Get("/interaction-stats", server.HandleGetInteractionStats)
|
||||
r.Get("/interactions", server.HandleListInteractions)
|
||||
r.Get("/interaction-content", server.HandleGetInteractionContent)
|
||||
})
|
||||
|
||||
t.Run("HandleGetInteractionStats", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/setup/interaction-stats", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var stats proxy.InteractionStats
|
||||
if err := json.NewDecoder(w.Body).Decode(&stats); err != nil {
|
||||
t.Fatalf("Failed to decode stats: %v", err)
|
||||
}
|
||||
|
||||
if stats.TotalRequests != 1 {
|
||||
t.Errorf("Expected 1 total request, got %d", stats.TotalRequests)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HandleListInteractions", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/setup/interactions?category=self", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var interactions []proxy.Interaction
|
||||
if err := json.NewDecoder(w.Body).Decode(&interactions); err != nil {
|
||||
t.Fatalf("Failed to decode interactions: %v", err)
|
||||
}
|
||||
|
||||
if len(interactions) != 1 {
|
||||
t.Errorf("Expected 1 interaction, got %d", len(interactions))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HandleGetInteractionContent", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/setup/interaction-content?file="+relPath, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
if !strings.Contains(w.Body.String(), "### GET /test") {
|
||||
t.Errorf("Unexpected content: %s", w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HandleGetInteractionContent_MissingFile", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/setup/interaction-content", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecordMiddleware(t *testing.T) {
|
||||
t.Setenv("RECORDER_ASYNC", "false")
|
||||
tmpDir, err := os.MkdirTemp("", "record-middleware-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
|
||||
server := &Server{
|
||||
ds: ds,
|
||||
recordEnabled: true,
|
||||
}
|
||||
recorder := proxy.NewRecorder(tmpDir)
|
||||
server.SetRecorder(recorder)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(server.RecordMiddleware)
|
||||
r.Get("/test-middleware", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Test", "Value")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write([]byte("created"))
|
||||
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/test-middleware", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("Expected status 201, got %d", w.Code)
|
||||
}
|
||||
|
||||
t.Run("HandleRecordMiddleware_Disabled", func(t *testing.T) {
|
||||
server.recordEnabled = false
|
||||
req := httptest.NewRequest("GET", "/test-middleware", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("Expected status 201, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) {
|
||||
target, _ := url.Parse(targetURL)
|
||||
proxy := &reverseProxy{target: target}
|
||||
server := &Server{ds: ds}
|
||||
server := NewServer(ds, nil, "http://localhost:8000", false, false, false, false)
|
||||
server.SetSoundcorkURL(targetURL)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(server.OriginMiddleware)
|
||||
r.Use(server.ShortcutMiddleware)
|
||||
r.Use(server.RecordMiddleware)
|
||||
|
||||
r.Get("/", server.HandleRoot)
|
||||
|
||||
// Setup media and web directories for tests
|
||||
@@ -29,6 +29,13 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
|
||||
})
|
||||
|
||||
// Legacy or direct domain calls without /bmx prefix
|
||||
r.Get("/registry/v1/services", server.HandleBMXRegistry)
|
||||
r.Get("/tunein/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
|
||||
r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
|
||||
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
|
||||
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
|
||||
|
||||
// Setup Marge for tests
|
||||
r.Route("/marge", func(r chi.Router) {
|
||||
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
|
||||
@@ -43,36 +50,93 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
|
||||
r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
|
||||
r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
})
|
||||
|
||||
// Legacy or direct domain calls without /marge prefix
|
||||
r.Get("/streaming/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Get("/accounts/{account}/full", server.HandleMargeAccountFull)
|
||||
r.Post("/streaming/support/power_on", server.HandleMargePowerOn)
|
||||
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
|
||||
r.Get("/accounts/{account}/devices/{device}/presets", server.HandleMargePresets)
|
||||
r.Post("/accounts/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
|
||||
r.Post("/accounts/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
|
||||
r.Post("/accounts/{account}/devices", server.HandleMargeAddDevice)
|
||||
r.Delete("/accounts/{account}/devices/{device}", server.HandleMargeRemoveDevice)
|
||||
r.Get("/streaming/account/{account}/provider_settings", server.HandleMargeProviderSettings)
|
||||
r.Get("/streaming/device/{device}/streaming_token", server.HandleMargeStreamingToken)
|
||||
r.Post("/streaming/support/customersupport", server.HandleMargeCustomerSupport)
|
||||
r.Get("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
|
||||
r.Post("/streaming/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
|
||||
r.Get("/streaming/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
|
||||
|
||||
// Setup Customer for tests
|
||||
r.Route("/customer", func(r chi.Router) {
|
||||
r.Get("/account/{account}", server.HandleMargeAccountProfile)
|
||||
r.Post("/account/{account}", server.HandleMargeUpdateAccountProfile)
|
||||
r.Post("/account/{account}/password", server.HandleMargeChangePassword)
|
||||
})
|
||||
|
||||
// Setup Devices for tests
|
||||
r.Route("/devices", func(r chi.Router) {
|
||||
r.Get("/", server.HandleListDiscoveredDevices)
|
||||
r.Post("/", server.HandleAddManualDevice)
|
||||
|
||||
r.Route("/{deviceId}", func(r chi.Router) {
|
||||
r.Delete("/", server.HandleRemoveDevice)
|
||||
r.Get("/events", server.HandleGetDeviceEvents)
|
||||
r.Get("/info", server.HandleGetDeviceInfo)
|
||||
r.Get("/ws", server.HandleDeviceWebSocket)
|
||||
r.Post("/key/{key}", server.HandleDeviceKey)
|
||||
r.Post("/volume/{level}", server.HandleDeviceVolume)
|
||||
r.Post("/reboot", server.HandleRebootDevice)
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/version", server.HandleGetVersionInfo)
|
||||
|
||||
// Setup Setup for tests
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
r.Post("/discover", server.HandleTriggerDiscovery)
|
||||
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
|
||||
r.Get("/settings", server.HandleGetSettings)
|
||||
r.Post("/settings", server.HandleUpdateSettings)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
r.Get("/proxy-settings", server.HandleGetProxySettings)
|
||||
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
|
||||
r.Post("/ensure-remote-services/{deviceIP}", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services/{deviceIP}", server.HandleRemoveRemoteServices)
|
||||
r.Post("/migrate/{deviceIP}", server.HandleMigrateDevice)
|
||||
r.Post("/revert/{deviceIP}", server.HandleRevertMigration)
|
||||
r.Post("/reboot/{deviceIP}", server.HandleRebootDevice)
|
||||
r.Post("/trust-ca/{deviceIP}", server.HandleTrustCACert)
|
||||
r.Post("/test-connection/{deviceIP}", server.HandleTestConnection)
|
||||
r.Post("/test-hosts/{deviceIP}", server.HandleTestHostsRedirection)
|
||||
r.Get("/ca.crt", server.HandleGetCACert)
|
||||
r.Get("/interaction-stats", server.HandleGetInteractionStats)
|
||||
r.Get("/interactions", server.HandleListInteractions)
|
||||
r.Get("/interaction-content", server.HandleGetInteractionContent)
|
||||
r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession)
|
||||
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
|
||||
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
|
||||
|
||||
r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
|
||||
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
|
||||
|
||||
r.Route("/devices/{deviceId}", func(r chi.Router) {
|
||||
r.Get("/summary", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate", server.HandleMigrateDevice)
|
||||
r.Post("/revert", server.HandleRevertMigration)
|
||||
r.Post("/trust-ca", server.HandleTrustCACert)
|
||||
r.Post("/ensure-remote-services", server.HandleEnsureRemoteServices)
|
||||
r.Post("/remove-remote-services", server.HandleRemoveRemoteServices)
|
||||
r.Post("/backup", server.HandleBackupConfig)
|
||||
r.Post("/sync", server.HandleInitialSync)
|
||||
r.Post("/test-connection", server.HandleTestConnection)
|
||||
r.Post("/test-hosts", server.HandleTestHostsRedirection)
|
||||
r.Post("/test-dns", server.HandleTestDNSRedirection)
|
||||
})
|
||||
})
|
||||
|
||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||
proxy.ServeHTTP(w, r)
|
||||
})
|
||||
r.NotFound(server.HandleNotFound)
|
||||
|
||||
return r, server
|
||||
}
|
||||
|
||||
type reverseProxy struct {
|
||||
target *url.URL
|
||||
}
|
||||
|
||||
func (p *reverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Simplified proxy for testing
|
||||
w.WriteHeader(http.StatusAccepted) // Custom status to identify proxy hit in tests
|
||||
_, _ = w.Write([]byte("Proxied to " + p.target.String()))
|
||||
func init() {
|
||||
// Silence logger for tests
|
||||
// log.SetOutput(io.Discard)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// OriginMiddleware returns a middleware that logs whether the request was handled "self" or "upstream".
|
||||
func (s *Server) OriginMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
origin := "self"
|
||||
if ww.Header().Get("X-Proxy-Origin") != "" {
|
||||
origin = "upstream"
|
||||
}
|
||||
|
||||
log.Printf("[LOG] %s %s | %d | %s | %v", r.Method, r.URL.Path, ww.Status(), origin, time.Since(start))
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
@@ -12,7 +13,7 @@ import (
|
||||
// RecordMiddleware returns a middleware that records "self" requests and responses.
|
||||
func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.recorder == nil || !s.recordEnabled {
|
||||
if s.recorder == nil || !s.recordEnabled || r.Header.Get("Upgrade") == "websocket" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -39,6 +40,10 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
// Create a response object for the recorder
|
||||
res := rw.getRecordedResponse(r)
|
||||
if res.StatusCode >= 400 {
|
||||
log.Printf("[DEBUG_LOG] Recording error response: %d %s %s", res.StatusCode, r.Method, r.URL.Path)
|
||||
}
|
||||
|
||||
if res.Body != nil {
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
}
|
||||
@@ -52,8 +57,9 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
body *bytes.Buffer
|
||||
statusCode int
|
||||
body *bytes.Buffer
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Header() http.Header {
|
||||
@@ -61,18 +67,28 @@ func (rw *responseWriter) Header() http.Header {
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
if rw.wroteHeader {
|
||||
return
|
||||
}
|
||||
|
||||
rw.statusCode = code
|
||||
rw.wroteHeader = true
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||
if !rw.wroteHeader {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
rw.body.Write(b)
|
||||
|
||||
return rw.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) getRecordedResponse(r *http.Request) *http.Response {
|
||||
statusCode := rw.statusCode
|
||||
if statusCode == 0 {
|
||||
if !rw.wroteHeader && statusCode == 0 {
|
||||
statusCode = http.StatusOK
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
@@ -10,44 +14,275 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
)
|
||||
|
||||
// Server handles HTTP requests for the SoundTouch service.
|
||||
type Server struct {
|
||||
ds *datastore.DataStore
|
||||
sm *setup.Manager
|
||||
serverURL string
|
||||
proxyURL string
|
||||
discovering bool
|
||||
proxyRedact bool
|
||||
proxyLogBody bool
|
||||
recordEnabled bool
|
||||
recorder *proxy.Recorder
|
||||
ds *datastore.DataStore
|
||||
sm *setup.Manager
|
||||
mu sync.RWMutex
|
||||
serverURL string
|
||||
soundcorkURL string
|
||||
httpsServerURL string
|
||||
discovering bool
|
||||
proxyRedact bool
|
||||
proxyLogBody bool
|
||||
recordEnabled bool
|
||||
discoveryInterval time.Duration
|
||||
discoveryEnabled bool
|
||||
dnsEnabled bool
|
||||
dnsUpstream string
|
||||
dnsBindAddr string
|
||||
enableSoundcorkProxy bool
|
||||
shortcuts map[string]int
|
||||
recorder *proxy.Recorder
|
||||
dnsDiscovery *discovery.DNSDiscovery
|
||||
UpstreamProxy http.Handler
|
||||
Version string
|
||||
Commit string
|
||||
Date string
|
||||
mgmtUsername string
|
||||
mgmtPassword string
|
||||
spotifyClientID string
|
||||
spotifyClientSecret string
|
||||
spotifyRedirectURI string
|
||||
baseURL string
|
||||
spotifyService *spotify.Service
|
||||
}
|
||||
|
||||
// NewServer creates a new SoundTouch service server.
|
||||
func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, proxyRedact, proxyLogBody, recordEnabled bool) *Server {
|
||||
return &Server{
|
||||
ds: ds,
|
||||
sm: sm,
|
||||
serverURL: serverURL,
|
||||
proxyURL: serverURL,
|
||||
proxyRedact: proxyRedact,
|
||||
proxyLogBody: proxyLogBody,
|
||||
recordEnabled: recordEnabled,
|
||||
func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, proxyRedact, proxyLogBody, recordEnabled, enableSoundcorkProxy bool) *Server {
|
||||
s := &Server{
|
||||
ds: ds,
|
||||
sm: sm,
|
||||
serverURL: serverURL,
|
||||
soundcorkURL: "http://localhost:8001",
|
||||
proxyRedact: proxyRedact,
|
||||
proxyLogBody: proxyLogBody,
|
||||
recordEnabled: recordEnabled,
|
||||
enableSoundcorkProxy: enableSoundcorkProxy,
|
||||
discoveryInterval: 5 * time.Minute,
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// SetVersionInfo sets the version information for the server.
|
||||
func (s *Server) SetVersionInfo(version, commit, date string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.Version = version
|
||||
s.Commit = commit
|
||||
s.Date = date
|
||||
}
|
||||
|
||||
// SetDiscoverySettings sets the discovery settings for the server.
|
||||
func (s *Server) SetDiscoverySettings(interval time.Duration, enabled bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.discoveryInterval = interval
|
||||
s.discoveryEnabled = enabled
|
||||
}
|
||||
|
||||
// SetDNSSettings sets the DNS discovery settings for the server.
|
||||
func (s *Server) SetDNSSettings(enabled bool, upstream, bind string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
oldBind := s.dnsBindAddr
|
||||
oldUpstream := s.dnsUpstream
|
||||
|
||||
s.dnsEnabled = enabled
|
||||
s.dnsUpstream = upstream
|
||||
s.dnsBindAddr = bind
|
||||
|
||||
if s.dnsDiscovery != nil {
|
||||
if !enabled || bind != oldBind || upstream != oldUpstream {
|
||||
log.Printf("[DNS] Settings changed, stopping DNS discovery server")
|
||||
|
||||
_ = s.dnsDiscovery.Shutdown()
|
||||
s.dnsDiscovery = nil
|
||||
}
|
||||
}
|
||||
|
||||
if enabled && upstream == "" {
|
||||
log.Printf("[DNS] Cannot start DNS discovery server: upstream DNS is empty")
|
||||
|
||||
s.dnsEnabled = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if enabled && s.dnsDiscovery == nil {
|
||||
log.Printf("[DNS] Starting DNS discovery server on %s", bind)
|
||||
|
||||
u, _ := url.Parse(s.serverURL)
|
||||
|
||||
serviceIP := u.Hostname()
|
||||
if serviceIP == "localhost" || serviceIP == "" {
|
||||
serviceIP = "127.0.0.1"
|
||||
}
|
||||
|
||||
if s.sm != nil {
|
||||
serviceIP = s.sm.GetResolvedIP(serviceIP)
|
||||
}
|
||||
|
||||
s.dnsDiscovery = discovery.NewDNSDiscovery(upstream, serviceIP)
|
||||
go func(d *discovery.DNSDiscovery, addr string) {
|
||||
if err := d.Start(addr); err != nil {
|
||||
log.Printf("Warning: DNS discovery server error: %v", err)
|
||||
}
|
||||
}(s.dnsDiscovery, bind)
|
||||
}
|
||||
}
|
||||
|
||||
// GetDNSRunning returns whether DNS discovery is active and its bind address.
|
||||
func (s *Server) GetDNSRunning() (bool, string) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if s.dnsDiscovery == nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
return s.dnsDiscovery.IsRunning(s.dnsBindAddr), s.dnsBindAddr
|
||||
}
|
||||
|
||||
// SetDNSDiscoveries sets the initial DNS discoveries for the server.
|
||||
func (s *Server) SetDNSDiscoveries(discoveries map[string]*discovery.DiscoveredHost) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.dnsDiscovery != nil {
|
||||
s.dnsDiscovery.SetDiscovered(discoveries)
|
||||
}
|
||||
}
|
||||
|
||||
// GetDNSDiscovery returns the current DNS discoveries.
|
||||
func (s *Server) GetDNSDiscovery() map[string]*discovery.DiscoveredHost {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if s.dnsDiscovery == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.dnsDiscovery.GetDiscovered()
|
||||
}
|
||||
|
||||
// SetShortcuts sets the request shortcuts for the server.
|
||||
func (s *Server) SetShortcuts(shortcuts map[string]int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.shortcuts = shortcuts
|
||||
}
|
||||
|
||||
// GetShortcuts returns the current request shortcuts.
|
||||
func (s *Server) GetShortcuts() map[string]int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.shortcuts
|
||||
}
|
||||
|
||||
// GetDiscoverySettings returns the current discovery settings.
|
||||
func (s *Server) GetDiscoverySettings() (time.Duration, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.discoveryInterval, s.discoveryEnabled
|
||||
}
|
||||
|
||||
// SetHTTPServerURL sets the external HTTPS URL of the service.
|
||||
func (s *Server) SetHTTPServerURL(url string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.httpsServerURL = url
|
||||
}
|
||||
|
||||
// SetSoundcorkURL sets the URL for the Soundcork backend.
|
||||
func (s *Server) SetSoundcorkURL(url string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.soundcorkURL = url
|
||||
}
|
||||
|
||||
// SetRecorder sets the recorder for the server.
|
||||
func (s *Server) SetRecorder(r *proxy.Recorder) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.recorder = r
|
||||
if r != nil {
|
||||
r.Redact = s.proxyRedact
|
||||
}
|
||||
}
|
||||
|
||||
// SetSpotifyConfig sets the Spotify OAuth configuration.
|
||||
func (s *Server) SetSpotifyConfig(clientID, clientSecret, redirectURI string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.spotifyClientID = clientID
|
||||
s.spotifyClientSecret = clientSecret
|
||||
s.spotifyRedirectURI = redirectURI
|
||||
}
|
||||
|
||||
// SetMgmtConfig sets the management API authentication credentials.
|
||||
func (s *Server) SetMgmtConfig(username, password string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.mgmtUsername = username
|
||||
s.mgmtPassword = password
|
||||
}
|
||||
|
||||
// SetBaseURL sets the external base URL for OAuth callbacks.
|
||||
func (s *Server) SetBaseURL(baseURL string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.baseURL = baseURL
|
||||
}
|
||||
|
||||
// SetSpotifyService sets the Spotify OAuth service.
|
||||
func (s *Server) SetSpotifyService(ss *spotify.Service) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.spotifyService = ss
|
||||
}
|
||||
|
||||
// GetRecordEnabled returns whether recording is enabled.
|
||||
func (s *Server) GetRecordEnabled() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.recordEnabled
|
||||
}
|
||||
|
||||
// GetSettings returns the current server settings.
|
||||
func (s *Server) GetSettings() (string, string, string) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.serverURL, s.soundcorkURL, s.httpsServerURL
|
||||
}
|
||||
|
||||
// GetProxySettings returns the current proxy settings.
|
||||
func (s *Server) GetProxySettings() (bool, bool, bool, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.proxyRedact, s.proxyLogBody, s.recordEnabled, s.enableSoundcorkProxy
|
||||
}
|
||||
|
||||
// DiscoverDevices starts a background device discovery process.
|
||||
//
|
||||
//nolint:contextcheck
|
||||
@@ -78,12 +313,15 @@ func (s *Server) DiscoverDevices(ctx context.Context) {
|
||||
for _, d := range devices {
|
||||
s.handleDiscoveredDevice(*d)
|
||||
}
|
||||
|
||||
// Post-discovery cleanup: merge overlapping IP/Serial entries
|
||||
s.mergeOverlappingDevices()
|
||||
}
|
||||
|
||||
func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) {
|
||||
log.Printf("Discovered Bose device: %s at %s (Serial: %s)", d.Name, d.Host, d.SerialNo)
|
||||
|
||||
// 1. Check if we already have this device by serial number (best identifier)
|
||||
// 1. Check if we already have this device
|
||||
existingID := s.findExistingDeviceID(d)
|
||||
|
||||
// Use SerialNo if available, otherwise fallback to IP for the datastore directory name
|
||||
@@ -103,8 +341,33 @@ func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) {
|
||||
deviceID = d.Host
|
||||
}
|
||||
|
||||
accountID := ""
|
||||
|
||||
if liveInfo, err := s.sm.GetLiveDeviceInfo(d.Host); err == nil {
|
||||
if liveInfo.MargeAccountUUID != "" {
|
||||
accountID = liveInfo.MargeAccountUUID
|
||||
}
|
||||
|
||||
if liveInfo.SerialNumber != "" {
|
||||
d.SerialNo = liveInfo.SerialNumber
|
||||
deviceID = d.SerialNo
|
||||
}
|
||||
}
|
||||
|
||||
if accountID == "" {
|
||||
// Try to find account ID from existing device entries if live info failed
|
||||
if existing := s.findExistingDeviceInfo(d); existing != nil {
|
||||
accountID = existing.AccountID
|
||||
}
|
||||
}
|
||||
|
||||
if accountID == "" {
|
||||
accountID = "default"
|
||||
}
|
||||
|
||||
info := &models.ServiceDeviceInfo{
|
||||
DeviceID: d.SerialNo,
|
||||
DeviceID: deviceID,
|
||||
AccountID: accountID,
|
||||
Name: d.Name,
|
||||
IPAddress: d.Host,
|
||||
DeviceSerialNumber: d.SerialNo,
|
||||
@@ -116,26 +379,124 @@ func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) {
|
||||
// If we had an IP-based entry and now have a Serial, clean up the IP-based entry
|
||||
if d.SerialNo != "" && existingID != "" && existingID != d.SerialNo {
|
||||
log.Printf("Device %s previously known as %s, migrating to serial-based ID %s", d.Name, existingID, d.SerialNo)
|
||||
_ = s.ds.RemoveDevice("default", existingID)
|
||||
_ = s.ds.RemoveDevice(accountID, existingID)
|
||||
}
|
||||
|
||||
if err := s.ds.SaveDeviceInfo("default", deviceID, info); err != nil {
|
||||
if err := s.ds.SaveDeviceInfo(accountID, deviceID, info); err != nil {
|
||||
log.Printf("Failed to save device info: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) findExistingDeviceID(d models.DiscoveredDevice) string {
|
||||
allDevices, _ := s.ds.ListAllDevices()
|
||||
func (s *Server) mergeOverlappingDevices() {
|
||||
allDevices, err := s.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Group devices by IP
|
||||
byIP := make(map[string][]models.ServiceDeviceInfo)
|
||||
|
||||
for i := range allDevices {
|
||||
known := allDevices[i]
|
||||
if d.SerialNo != "" && (known.DeviceID == d.SerialNo || known.DeviceSerialNumber == d.SerialNo) {
|
||||
if known.DeviceID != "" {
|
||||
return known.DeviceID
|
||||
dev := allDevices[i]
|
||||
if dev.IPAddress != "" {
|
||||
byIP[dev.IPAddress] = append(byIP[dev.IPAddress], dev)
|
||||
}
|
||||
}
|
||||
|
||||
for ip, devices := range byIP {
|
||||
if len(devices) <= 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
// We have multiple entries for the same IP.
|
||||
// Try to find one with a Serial Number to be the master.
|
||||
var master *models.ServiceDeviceInfo
|
||||
|
||||
for i := range devices {
|
||||
if devices[i].DeviceSerialNumber != "" {
|
||||
master = &devices[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if master == nil {
|
||||
// Fallback: look for one with DeviceID that isn't the IP
|
||||
for i := range devices {
|
||||
if devices[i].DeviceID != "" && devices[i].DeviceID != devices[i].IPAddress {
|
||||
master = &devices[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if master == nil {
|
||||
// None have serials, just keep the first one
|
||||
continue
|
||||
}
|
||||
|
||||
masterID := master.DeviceID
|
||||
if masterID == "" {
|
||||
masterID = master.DeviceSerialNumber
|
||||
}
|
||||
|
||||
for i := range devices {
|
||||
dev := devices[i]
|
||||
devID := dev.DeviceID
|
||||
|
||||
if devID == "" {
|
||||
devID = dev.IPAddress
|
||||
}
|
||||
|
||||
return known.IPAddress
|
||||
if devID != masterID && dev.IPAddress == ip {
|
||||
log.Printf("Merging overlapping device entry %s into %s (IP: %s)", devID, masterID, ip)
|
||||
_ = s.ds.RemoveDevice(dev.AccountID, devID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) findExistingDeviceID(d models.DiscoveredDevice) string {
|
||||
info := s.findExistingDeviceInfo(d)
|
||||
if info != nil {
|
||||
return info.DeviceID
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) findExistingDeviceInfo(d models.DiscoveredDevice) *models.ServiceDeviceInfo {
|
||||
allDevices, _ := s.ds.ListAllDevices()
|
||||
for i := range allDevices {
|
||||
known := allDevices[i]
|
||||
// Match by Serial
|
||||
if d.SerialNo != "" && (known.DeviceID == d.SerialNo || known.DeviceSerialNumber == d.SerialNo) {
|
||||
return &known
|
||||
}
|
||||
// Match by IP
|
||||
if d.Host != "" && known.IPAddress == d.Host {
|
||||
return &known
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// lookupIP resolves a deviceId to its last known device IP.
|
||||
func (s *Server) lookupIP(deviceId string) (string, error) {
|
||||
devices, err := s.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for i := range devices {
|
||||
if devices[i].DeviceID == deviceId {
|
||||
if devices[i].IPAddress == "" {
|
||||
return "", fmt.Errorf("no IP known for deviceId %s", deviceId)
|
||||
}
|
||||
|
||||
return devices[i].IPAddress, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("deviceId %s not found", deviceId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func TestMergeOverlappingDevices(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "merge-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
s := &Server{ds: ds}
|
||||
|
||||
// Case 1: IP-only entry and Serial-based entry for the same IP
|
||||
ip := "192.168.1.100"
|
||||
serial := "SERIAL123"
|
||||
|
||||
// 1. Save IP-based entry
|
||||
infoIP := &models.ServiceDeviceInfo{
|
||||
DeviceID: ip,
|
||||
Name: "Speaker IP",
|
||||
IPAddress: ip,
|
||||
AccountID: "default",
|
||||
ProductCode: "ST10",
|
||||
}
|
||||
err = ds.SaveDeviceInfo("default", ip, infoIP)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save IP info: %v", err)
|
||||
}
|
||||
|
||||
// 2. Save Serial-based entry
|
||||
infoSerial := &models.ServiceDeviceInfo{
|
||||
DeviceID: serial,
|
||||
DeviceSerialNumber: serial,
|
||||
Name: "Speaker Serial",
|
||||
IPAddress: ip,
|
||||
AccountID: "default",
|
||||
ProductCode: "ST10",
|
||||
}
|
||||
err = ds.SaveDeviceInfo("default", serial, infoSerial)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save Serial info: %v", err)
|
||||
}
|
||||
|
||||
// Verify both exist
|
||||
devices, _ := ds.ListAllDevices()
|
||||
if len(devices) != 2 {
|
||||
t.Fatalf("Expected 2 devices before merge, got %d", len(devices))
|
||||
}
|
||||
|
||||
// Run merge
|
||||
s.mergeOverlappingDevices()
|
||||
|
||||
// Verify merge
|
||||
devices, _ = ds.ListAllDevices()
|
||||
if len(devices) != 1 {
|
||||
t.Fatalf("Expected 1 device after merge, got %d", len(devices))
|
||||
}
|
||||
|
||||
if devices[0].DeviceID != serial {
|
||||
t.Errorf("Expected remaining device to be Serial-based (%s), got %s", serial, devices[0].DeviceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindExistingDeviceID(t *testing.T) {
|
||||
tempDir, _ := os.MkdirTemp("", "find-test-*")
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
s := &Server{ds: ds}
|
||||
|
||||
ip := "192.168.1.101"
|
||||
serial := "SERIAL456"
|
||||
|
||||
// Save IP-based
|
||||
ds.SaveDeviceInfo("default", ip, &models.ServiceDeviceInfo{
|
||||
DeviceID: ip,
|
||||
IPAddress: ip,
|
||||
Name: "IP Speaker",
|
||||
AccountID: "default",
|
||||
ProductCode: "ST10",
|
||||
})
|
||||
|
||||
// Test finding by IP
|
||||
foundID := s.findExistingDeviceID(models.DiscoveredDevice{
|
||||
Host: ip,
|
||||
})
|
||||
if foundID != ip {
|
||||
t.Errorf("Expected to find by IP, got %s", foundID)
|
||||
}
|
||||
|
||||
// Save Serial-based for SAME IP
|
||||
ds.SaveDeviceInfo("default", serial, &models.ServiceDeviceInfo{
|
||||
DeviceID: serial,
|
||||
DeviceSerialNumber: serial,
|
||||
IPAddress: ip,
|
||||
Name: "Serial Speaker",
|
||||
AccountID: "default",
|
||||
ProductCode: "ST10",
|
||||
})
|
||||
|
||||
// Test finding by IP should now return Serial (if Serial is known)
|
||||
// Actually findExistingDeviceID returns the first match it finds in allDevices.
|
||||
// Since we haven't merged yet, it could be either.
|
||||
|
||||
// Test finding by Serial
|
||||
foundID = s.findExistingDeviceID(models.DiscoveredDevice{
|
||||
Host: ip,
|
||||
SerialNo: serial,
|
||||
})
|
||||
if foundID != serial && foundID != ip {
|
||||
t.Errorf("Expected to find by Serial or IP, got %s", foundID)
|
||||
}
|
||||
|
||||
// Merge and check again
|
||||
s.mergeOverlappingDevices()
|
||||
foundID = s.findExistingDeviceID(models.DiscoveredDevice{
|
||||
Host: ip,
|
||||
})
|
||||
if foundID != serial {
|
||||
t.Errorf("After merge, expected to find Serial ID for IP, got %s", foundID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// ShortcutMiddleware returns a middleware that shortcuts requests to specific paths.
|
||||
func (s *Server) ShortcutMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
shortcuts := s.shortcuts
|
||||
s.mu.RUnlock()
|
||||
|
||||
if shortcuts != nil {
|
||||
if status, ok := shortcuts[r.URL.Path]; ok {
|
||||
w.WriteHeader(status)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -170,6 +170,46 @@
|
||||
"liveRadio",
|
||||
"onDemand"
|
||||
]
|
||||
},
|
||||
{
|
||||
"_links": {
|
||||
"bmx_navigate": {
|
||||
"href": "/v1/navigate"
|
||||
},
|
||||
"bmx_token": {
|
||||
"href": "/v1/token"
|
||||
},
|
||||
"self": {
|
||||
"href": "/"
|
||||
}
|
||||
},
|
||||
"askAdapter": false,
|
||||
"assets": {
|
||||
"color": "#000000",
|
||||
"description": "RadioBrowser is an open source internet radio directory. It provides access to thousands of internet radio stations worldwide. RadioBrowser is community driven and relies on user contributions to keep the station database up to date.",
|
||||
"icons": {
|
||||
"largeSvg": "{MEDIA_SERVER}/orion-monochrome.svg",
|
||||
"monochromePng": "{MEDIA_SERVER}/orion-monochrome_v2.png",
|
||||
"monochromeSvg": "{MEDIA_SERVER}/orion-monochrome.svg",
|
||||
"smallSvg": "{MEDIA_SERVER}/orion-monochrome.svg"
|
||||
},
|
||||
"name": "RadioBrowser"
|
||||
},
|
||||
"authenticationModel": {
|
||||
"anonymousAccount": {
|
||||
"autoCreate": true,
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"baseUrl": "https://all.api.radio-browser.info/soundtouch",
|
||||
"id": {
|
||||
"name": "RADIO_BROWSER",
|
||||
"value": 39
|
||||
},
|
||||
"streamTypes": [
|
||||
"liveRadio",
|
||||
"onDemand"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 418 B After Width: | Height: | Size: 418 B |
|
Before Width: | Height: | Size: 681 B After Width: | Height: | Size: 681 B |
|
Before Width: | Height: | Size: 859 B After Width: | Height: | Size: 859 B |
|
Before Width: | Height: | Size: 246 B After Width: | Height: | Size: 246 B |
|
Before Width: | Height: | Size: 381 B After Width: | Height: | Size: 381 B |
@@ -1,6 +1,6 @@
|
||||
# Favicon Meanings
|
||||
|
||||
This directory contains favicons for the Soundcork project in various formats (SVG, PNG, ICO). The icons use Morse code and Braille to represent the initials **S** (Sound) and **T** (Touch).
|
||||
This directory contains favicons for the service in various formats (SVG, PNG, ICO). The icons use Morse code and Braille to represent the initials **S** (Sound) and **T** (Touch).
|
||||
|
||||
## Morse Variant (`favicon-morse.*`)
|
||||
The icon represents the letters **S** and **T** in international Morse code:
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 957 B After Width: | Height: | Size: 957 B |
|
Before Width: | Height: | Size: 631 B After Width: | Height: | Size: 631 B |