Compare commits

...
17 Commits
Author SHA1 Message Date
Tobias Gesellchen 505e6dd760 Refactor data storage to use account-based hierarchy and update Web UI 2026-02-15 15:36:01 +01:00
Tobias Gesellchen 735187cae8 docs: link README.md in SUMMARY.md to fix TestDocsConsistency 2026-02-15 00:50:29 +01:00
Tobias Gesellchen e8622cc382 docs: add Jekyll build step to workflow 2026-02-15 00:38:04 +01:00
Tobias Gesellchen c59052bdb4 docs: improve Jekyll configuration with minimal theme and relative links plugin 2026-02-15 00:35:32 +01:00
Tobias Gesellchen 15a6c4b0a0 docs: add Jekyll configuration with Cayman theme 2026-02-15 00:35:19 +01:00
Tobias Gesellchen ae3a3765db docs: add landing page for GitHub Pages 2026-02-15 00:32:32 +01:00
Tobias Gesellchen 59019cf55c docs: deploy documentation to GitHub Pages and update links in Web UI and README 2026-02-15 00:30:28 +01:00
Tobias Gesellchen 5e612e57ec Fix golangci-lint issues in main.go 2026-02-15 00:27:25 +01:00
Tobias Gesellchen 02026a9f3a Add configurable shortcuts and log them on startup 2026-02-15 00:27:25 +01:00
Tobias Gesellchen aaf067088a Auto-create missing configuration files with default values 2026-02-15 00:27:25 +01:00
Tobias Gesellchen 358ea18138 Implement device merging logic and web-based device removal 2026-02-15 00:15:09 +01:00
Tobias Gesellchen 0c5c1803a5 docs: fix broken documentation links across multiple files 2026-02-14 23:03:53 +01:00
Tobias Gesellchen cdf80a793e docs: fix broken documentation links across multiple files 2026-02-14 23:03:53 +01:00
Tobias Gesellchen b511e052e2 docs: fix broken documentation links and update CI workflow paths 2026-02-14 23:03:53 +01:00
Tobias Gesellchen b7197a8679 Add version visibility and discovery controls to Web UI and API 2026-02-14 23:03:53 +01:00
Tobias Gesellchen 5bfc24b7fb Use v0.18.1 version as default 2026-02-14 22:21:43 +01:00
Tobias Gesellchen 1e61adbb46 Integrate self-update logic into Raspberry Pi installer and simplify update workflow 2026-02-14 22:21:43 +01:00
52 changed files with 1155 additions and 258 deletions
+3 -3
View File
@@ -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"
+37
View File
@@ -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@v4
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Build with Jekyll
uses: actions/jekyll-build-pages@v1
with:
source: 'docs/'
destination: '_site'
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: '_site'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+4 -4
View File
@@ -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.
+24 -24
View File
@@ -61,7 +61,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)
@@ -82,11 +82,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
@@ -379,19 +379,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
@@ -526,13 +526,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)
---
+63 -8
View File
@@ -5,6 +5,7 @@ package main
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"log"
"net/http"
@@ -138,7 +139,9 @@ func main() {
ds := initDataStore(config.dataDir)
// Load settings from datastore
persisted, _ := ds.GetSettings()
persisted, err := ds.GetSettings()
settingsExist := err == nil && persisted.ServerURL != ""
if persisted.ServerURL != "" {
config.serverURL = persisted.ServerURL
}
@@ -151,10 +154,33 @@ func main() {
config.httpsServerURL = persisted.HTTPServerURL
}
if persisted.DiscoveryInterval != "" {
if d, durErr := time.ParseDuration(persisted.DiscoveryInterval); durErr == nil {
config.discoveryInterval = d
}
}
config.redact = persisted.RedactLogs || config.redact
config.logBody = persisted.LogBodies || config.logBody
config.record = persisted.RecordInteractions || config.record
if !settingsExist {
log.Printf("Creating default settings.json in %s", config.dataDir)
persisted.ServerURL = config.serverURL
persisted.ProxyURL = config.targetURL
persisted.HTTPServerURL = config.httpsServerURL
persisted.RedactLogs = config.redact
persisted.LogBodies = config.logBody
persisted.RecordInteractions = config.record
persisted.DiscoveryInterval = config.discoveryInterval.String()
persisted.DiscoveryDisabled = false
persisted.Shortcuts = map[string]int{
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
"/sw.js": http.StatusNotFound,
}
_ = ds.SaveSettings(persisted)
}
// Recalculate domains if settings changed
hostname, _ := os.Hostname()
if hostname == "" {
@@ -167,18 +193,40 @@ func main() {
sm := setup.NewManager(config.serverURL, ds, cm)
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record)
server.SetHTTPServerURL(config.httpsServerURL)
server.SetVersionInfo(version, commit, date)
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryDisabled)
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)
@@ -188,7 +236,7 @@ func main() {
pyProxy := setupPythonProxy(config.targetURL, config.redact, config.logBody, recorder, server)
startDeviceDiscovery(server, config.discoveryInterval)
startDeviceDiscovery(server)
r := setupRouter(server, pyProxy)
@@ -392,11 +440,15 @@ func setupPythonProxy(targetURL string, redact, logBody bool, recorder *proxy.Re
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, disabled := server.GetDiscoverySettings()
if !disabled {
server.DiscoverDevices(context.Background())
}
time.Sleep(currentInterval)
}
}()
}
@@ -405,6 +457,7 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(server.ShortcutMiddleware)
r.Use(server.RecordMiddleware)
r.Get("/", server.HandleRoot)
@@ -451,6 +504,7 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
r.Route("/setup", func(r chi.Router) {
r.Get("/devices", server.HandleListDiscoveredDevices)
r.Post("/devices", server.HandleAddManualDevice)
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
r.Post("/discover", server.HandleTriggerDiscovery)
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
r.Get("/settings", server.HandleGetSettings)
@@ -470,6 +524,7 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
r.Get("/ca.crt", server.HandleGetCACert)
r.Get("/proxy-settings", server.HandleGetProxySettings)
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
r.Get("/version", server.HandleGetVersionInfo)
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
})
+2
View File
@@ -1,4 +1,6 @@
accounts/
certs/
default/
interactions/
patterns.json
settings.json
-17
View File
@@ -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}"
}
]
+2 -3
View File
@@ -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
+4 -3
View File
@@ -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!
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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).*
+5 -5
View File
@@ -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)
+32
View File
@@ -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).
+2 -2
View File
@@ -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.
+8 -8
View File
@@ -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! 🎵
+4 -1
View File
@@ -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)
@@ -15,7 +17,8 @@
### Useful Links
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
* [Raspberry Pi Installer](../../scripts/raspberry-pi/README.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
+11
View File
@@ -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
+1 -1
View File
@@ -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
+6 -6
View File
@@ -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
+3 -3
View File
@@ -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
+10
View File
@@ -37,6 +37,16 @@ sudo \
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:
+1
View File
@@ -161,6 +161,7 @@ The service supports multiple ways to configure its behavior. When multiple sour
| `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` |
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
### Configuration Examples
+4 -4
View File
@@ -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
+2 -2
View File
@@ -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.
+5 -5
View File
@@ -345,13 +345,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
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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)
+4 -4
View File
@@ -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)
+4 -4
View File
@@ -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
+4 -4
View File
@@ -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
+5 -5
View File
@@ -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)
+1
View File
@@ -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.
+17 -24
View File
@@ -42,12 +42,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 +134,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 +154,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 soundcork-go/data/accounts if it's different and exists
altDir := "soundcork-go/data/accounts"
if filepath.Join(ds.DataDir, "accounts") != altDir && exists(altDir) {
dirs = append(dirs, altDir)
}
@@ -638,17 +639,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
}
@@ -708,12 +698,15 @@ func (ds *DataStore) GetETagForAccount(account, device string) int64 {
// Settings represents the global service settings.
type Settings struct {
ServerURL string `json:"server_url"`
ProxyURL string `json:"proxy_url"`
HTTPServerURL string `json:"https_server_url,omitempty"`
RedactLogs bool `json:"redact_logs"`
LogBodies bool `json:"log_bodies"`
RecordInteractions bool `json:"record_interactions"`
ServerURL string `json:"server_url"`
ProxyURL string `json:"proxy_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"`
DiscoveryDisabled bool `json:"discovery_disabled"`
Shortcuts map[string]int `json:"shortcuts,omitempty"`
}
// GetSettings retrieves the global service settings.
+21 -8
View File
@@ -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,7 +88,7 @@ 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))
}
@@ -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)
@@ -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
@@ -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
@@ -376,9 +381,11 @@ func TestSettingsPersistence(t *testing.T) {
ds := NewDataStore(tempDir)
settings := Settings{
ServerURL: "http://myserver:8000",
ProxyURL: "http://myproxy:8001",
LogBodies: true,
ServerURL: "http://myserver:8000",
ProxyURL: "http://myproxy:8001",
LogBodies: true,
DiscoveryInterval: "10m",
DiscoveryDisabled: true,
}
err = ds.SaveSettings(settings)
@@ -397,4 +404,10 @@ func TestSettingsPersistence(t *testing.T) {
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.DiscoveryDisabled != settings.DiscoveryDisabled {
t.Errorf("Expected DiscoveryDisabled %v, got %v", settings.DiscoveryDisabled, loaded.DiscoveryDisabled)
}
}
+1 -1
View File
@@ -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)
+5 -5
View File
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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 {
+97 -8
View File
@@ -6,6 +6,7 @@ import (
"log"
"net/http"
"os"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
@@ -63,6 +64,7 @@ func (s *Server) HandleAddManualDevice(w http.ResponseWriter, r *http.Request) {
}
s.handleDiscoveredDevice(d)
s.mergeOverlappingDevices()
w.Header().Set("Content-Type", "application/json")
@@ -91,16 +93,66 @@ 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")
serverURL, proxyURL, httpsServerURL := s.GetSettings()
s.mu.RLock()
serverURL, proxyURL, httpsServerURL := s.serverURL, s.proxyURL, s.httpsServerURL
discoveryInterval := s.discoveryInterval.String()
discoveryDisabled := s.discoveryDisabled
s.mu.RUnlock()
if err := json.NewEncoder(w).Encode(map[string]string{
"server_url": serverURL,
"proxy_url": proxyURL,
"https_server_url": httpsServerURL,
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"server_url": serverURL,
"proxy_url": proxyURL,
"https_server_url": httpsServerURL,
"discovery_interval": discoveryInterval,
"discovery_disabled": discoveryDisabled,
}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
@@ -110,17 +162,31 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
// HandleUpdateSettings updates the service settings.
func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
var settings struct {
ServerURL string `json:"server_url"`
ProxyURL string `json:"proxy_url"`
ServerURL string `json:"server_url"`
ProxyURL string `json:"proxy_url"`
DiscoveryInterval string `json:"discovery_interval"`
DiscoveryDisabled bool `json:"discovery_disabled"`
}
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
http.Error(w, err.Error(), 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.proxyURL = settings.ProxyURL
if settings.DiscoveryInterval != "" {
s.discoveryInterval = interval
}
s.discoveryDisabled = settings.DiscoveryDisabled
if s.sm != nil {
s.sm.ServerURL = settings.ServerURL
@@ -134,13 +200,15 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
currentHTTPS := s.httpsServerURL
log.Printf("Saving updated settings to %s/settings.json", s.ds.DataDir)
err := s.ds.SaveSettings(datastore.Settings{
err = s.ds.SaveSettings(datastore.Settings{
ServerURL: s.serverURL,
ProxyURL: s.proxyURL,
HTTPServerURL: currentHTTPS,
RedactLogs: currentRedact,
LogBodies: currentLogBody,
RecordInteractions: currentRecord,
DiscoveryInterval: s.discoveryInterval.String(),
DiscoveryDisabled: s.discoveryDisabled,
})
s.mu.Unlock()
@@ -491,6 +559,8 @@ func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Reques
// Persist to datastore
// Access fields directly since we already hold the lock
serverURL, proxyURL, httpsServerURL := s.serverURL, s.proxyURL, s.httpsServerURL
discoveryInterval := s.discoveryInterval.String()
discoveryDisabled := s.discoveryDisabled
log.Printf("Saving updated proxy settings to %s/settings.json", s.ds.DataDir)
err := s.ds.SaveSettings(datastore.Settings{
@@ -500,6 +570,8 @@ func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Reques
RedactLogs: s.proxyRedact,
LogBodies: s.proxyLogBody,
RecordInteractions: s.recordEnabled,
DiscoveryInterval: discoveryInterval,
DiscoveryDisabled: discoveryDisabled,
})
s.mu.Unlock()
@@ -651,3 +723,20 @@ 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
}
}
@@ -249,6 +249,95 @@ func TestMigrationAndCA(t *testing.T) {
}
}
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 := "test-account"
deviceID := "TEST-DEVICE-ID"
deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
t.Fatalf("Failed to create device dir: %v", err)
}
infoFile := filepath.Join(deviceDir, "DeviceInfo.xml")
infoXML := `<?xml version="1.0" encoding="UTF-8" ?><info deviceID="TEST-DEVICE-ID"><name>Test Device</name><type>SoundTouch 10</type></info>`
if err := os.WriteFile(infoFile, []byte(infoXML), 0644); err != nil {
t.Fatalf("Failed to create device info file: %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 + "/setup/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+"/setup/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 + "/setup/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{}
func (m *mockSSH) Run(command string) (string, error) {
+2
View File
@@ -47,6 +47,8 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server)
// Setup Setup for tests
r.Route("/setup", func(r chi.Router) {
r.Get("/devices", server.HandleListDiscoveredDevices)
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
r.Get("/settings", server.HandleGetSettings)
r.Post("/settings", server.HandleUpdateSettings)
r.Get("/proxy-settings", server.HandleGetProxySettings)
+185 -29
View File
@@ -15,32 +15,82 @@ import (
// Server handles HTTP requests for the SoundTouch service.
type Server struct {
ds *datastore.DataStore
sm *setup.Manager
mu sync.RWMutex
serverURL string
proxyURL string
httpsServerURL string
discovering bool
proxyRedact bool
proxyLogBody bool
recordEnabled bool
recorder *proxy.Recorder
ds *datastore.DataStore
sm *setup.Manager
mu sync.RWMutex
serverURL string
proxyURL string
httpsServerURL string
discovering bool
proxyRedact bool
proxyLogBody bool
recordEnabled bool
discoveryInterval time.Duration
discoveryDisabled bool
shortcuts map[string]int
recorder *proxy.Recorder
Version string
Commit string
Date string
}
// 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,
ds: ds,
sm: sm,
serverURL: serverURL,
proxyURL: serverURL,
proxyRedact: proxyRedact,
proxyLogBody: proxyLogBody,
recordEnabled: recordEnabled,
discoveryInterval: 5 * time.Minute,
}
}
// 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, disabled bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.discoveryInterval = interval
s.discoveryDisabled = disabled
}
// 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.discoveryDisabled
}
// SetHTTPServerURL sets the external HTTPS URL of the service.
func (s *Server) SetHTTPServerURL(url string) {
s.mu.Lock()
@@ -108,12 +158,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
@@ -133,8 +186,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,
@@ -146,26 +224,104 @@ 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
}
+130
View File
@@ -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)
})
}
+10
View File
@@ -66,3 +66,13 @@ pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px;
.info-box a:hover {
text-decoration: none;
}
.btn-danger {
background-color: #f44336;
color: white;
border: none;
padding: 5px 10px;
}
.btn-danger:hover {
background-color: #d32f2f;
}
+12 -4
View File
@@ -60,14 +60,14 @@
<div class="info-box safety-box">
<strong>⚠️ Safety First:</strong> Before starting any migration, please read our
<a href="/docs/guides/MIGRATION-SAFETY.md" target="_blank">Professional Migration & Safety Guide</a>.
<a href="https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html" target="_blank">Professional Migration & Safety Guide</a>.
The toolkit automatically creates backups, but understanding the process is key to a smooth transition.
</div>
<h3>Useful Links</h3>
<ul>
<li><a href="/docs/guides/SURVIVAL-GUIDE.md" target="_blank">Cloud Shutdown Survival Guide</a></li>
<li><a href="/docs/guides/CLI-REFERENCE.md" target="_blank">CLI Reference</a></li>
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html" target="_blank">Cloud Shutdown Survival Guide</a></li>
<li><a href="https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html" target="_blank">CLI Reference</a></li>
</ul>
</div>
@@ -87,7 +87,12 @@
<div style="margin-bottom: 20px;">
<label for="proxy-domain">Proxy Domain:</label>
<input type="text" id="proxy-domain" placeholder="http://192.168.x.x:8000" style="width: 300px;">
<span style="font-size: 0.8em; color: #666;">(Upstream proxy URL)</span>
<span style="font-size: 0.8em; color: #666;">(Upstream proxy URL - usually the same as Target Domain)</span>
</div>
<div style="margin-bottom: 20px;">
<label for="discovery-interval">Discovery Interval:</label>
<input type="text" id="discovery-interval" placeholder="5m" style="width: 100px;">
<label style="margin-left: 15px;"><input type="checkbox" id="discovery-disabled"> Disable Automated Discovery</label>
</div>
<div style="margin-bottom: 20px;">
<button onclick="updateSettings()">Save Settings</button>
@@ -270,5 +275,8 @@
</div>
<script src="/web/js/script.js"></script>
<footer style="margin-top: 50px; padding: 20px; border-top: 1px solid #eee; font-size: 0.8em; color: #888; text-align: center;">
<span id="version-info">SoundTouch Toolkit</span>
</footer>
</body>
</html>
+61 -6
View File
@@ -8,6 +8,12 @@ async function fetchSettings() {
if (settings.proxy_url) {
document.getElementById('proxy-domain').value = settings.proxy_url;
}
if (settings.discovery_interval) {
document.getElementById('discovery-interval').value = settings.discovery_interval;
}
if (settings.discovery_disabled !== undefined) {
document.getElementById('discovery-disabled').checked = settings.discovery_disabled;
}
fetchProxySettings();
} catch (error) {
console.error('Failed to fetch settings', error);
@@ -46,7 +52,9 @@ async function updateProxySettings() {
async function updateSettings() {
const settings = {
server_url: document.getElementById('target-domain').value,
proxy_url: document.getElementById('proxy-domain').value
proxy_url: document.getElementById('proxy-domain').value,
discovery_interval: document.getElementById('discovery-interval').value,
discovery_disabled: document.getElementById('discovery-disabled').checked
};
const status = document.getElementById('settings-status');
status.innerText = 'Saving...';
@@ -84,7 +92,7 @@ async function fetchDevices() {
if (devices.length === 0) {
container.innerHTML = 'No devices known yet.';
} else {
let html = '<table><tr><th>Name</th><th>IP Address</th><th>Model</th><th>Serial Number</th><th>Firmware</th><th>Method</th><th>Action</th></tr>';
let html = '<table><tr><th>Name & Model</th><th>IP Address</th><th>Device & Account ID</th><th>Firmware & Serial</th><th>Method</th><th>Action</th></tr>';
// Clear and repopulate selectors
const currentSyncVal = syncSelector.value;
@@ -96,15 +104,15 @@ async function fetchDevices() {
const methodLabel = d.discovery_method === 'manual' ? '👤 Manual' : '🔍 Auto';
html += `
<tr id="device-row-${d.ip_address.replace(/\./g, '-')}">
<td class="col-name">${d.name}</td>
<td class="col-name-model"><div class="col-name">${d.name}</div><div class="col-model" style="font-size: 0.8em; color: #666;">${d.product_code}</div></td>
<td class="col-ip">${d.ip_address}</td>
<td class="col-model">${d.product_code}</td>
<td class="col-serial">${d.device_serial_number}</td>
<td class="col-firmware">${d.firmware_version || '0.0.0'}</td>
<td class="col-ids"><div class="col-deviceid">${d.device_id}</div><div class="col-accountid" style="font-size: 0.8em; color: #666;">${d.account_id || 'default'}</div></td>
<td class="col-fw-serial"><div class="col-firmware">${d.firmware_version || '0.0.0'}</div><div class="col-serial" style="font-size: 0.8em; color: #666;">${d.device_serial_number}</div></td>
<td class="col-method">${methodLabel}</td>
<td>
<button onclick="prepareSync('${d.ip_address}')">Sync Data</button>
<button onclick="prepareMigration('${d.ip_address}')">Migrate</button>
<button class="btn-danger" onclick="removeDevice('${d.device_id}', '${d.name}')">Remove</button>
</td>
</tr>
`;
@@ -208,10 +216,24 @@ async function startSync() {
}
}
async function fetchVersion() {
try {
const response = await fetch('/setup/version');
const data = await response.json();
const info = document.getElementById('version-info');
if (info && data.version) {
info.innerText = `SoundTouch Toolkit ${data.version} (${data.commit}) - ${data.date}`;
}
} catch (error) {
console.error('Failed to fetch version info', error);
}
}
document.addEventListener('DOMContentLoaded', () => {
fetchSettings();
fetchDevices();
triggerDiscovery();
fetchVersion();
document.getElementById('sync-now-btn').onclick = startSync;
});
@@ -243,6 +265,27 @@ async function addManualDevice() {
}
}
async function removeDevice(deviceId, name) {
if (!confirm(`Are you sure you want to remove device "${name}"?`)) {
return;
}
try {
const response = await fetch(`/setup/devices/${deviceId}`, {
method: 'DELETE'
});
if (response.ok) {
fetchDevices();
} else {
const err = await response.text();
alert('Failed to remove device: ' + err);
}
} catch (error) {
alert('Error removing device: ' + error.message);
}
}
async function triggerDiscovery() {
const indicator = document.getElementById('discovery-indicator');
if (indicator) indicator.style.display = 'inline';
@@ -292,6 +335,12 @@ async function updateDeviceInfo(ip) {
const firmwareEl = row.querySelector('.col-firmware');
if (firmwareEl && info.softwareVersion) firmwareEl.innerText = info.softwareVersion;
const deviceIdEl = row.querySelector('.col-deviceid');
if (deviceIdEl && info.deviceID) deviceIdEl.innerText = info.deviceID;
const accountIdEl = row.querySelector('.col-accountid');
if (accountIdEl && info.margeAccountUUID) accountIdEl.innerText = info.margeAccountUUID;
}
} catch (error) {
console.warn('Failed to fetch live info for ' + ip, error);
@@ -352,6 +401,12 @@ async function showSummary(ip) {
const firmwareEl = row.querySelector('.col-firmware');
if (firmwareEl && summary.firmware_version) firmwareEl.innerText = summary.firmware_version;
const deviceIdEl = row.querySelector('.col-deviceid');
if (deviceIdEl && summary.device_id) deviceIdEl.innerText = summary.device_id;
const accountIdEl = row.querySelector('.col-accountid');
if (accountIdEl && summary.account_id) accountIdEl.innerText = summary.account_id;
}
document.getElementById('ssh-status').innerText = summary.ssh_success ? '✅ Success' : '❌ Failed';
+21 -8
View File
@@ -59,13 +59,26 @@ func (pp PathPatterns) Sanitize(segment string) (string, string) {
// DefaultPatterns returns the default set of path patterns.
func DefaultPatterns() PathPatterns {
p := PathPattern{
Name: "IPv4",
Regexp: `^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$`,
Replacement: "{ip}",
return PathPatterns{
{
Name: "IPv4",
Regexp: `^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$`,
Replacement: "{ip}",
},
{
Name: "UUID",
Regexp: `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`,
Replacement: "{uuid}",
},
{
Name: "AccountID",
Regexp: `^\d{5,10}$`,
Replacement: "{accountId}",
},
{
Name: "DeviceID",
Regexp: `^[0-9a-fA-F]{12}$`,
Replacement: "{device_id}",
},
}
re, _ := regexp.Compile(p.Regexp)
p.compiled = re
return PathPatterns{p}
}
+61
View File
@@ -0,0 +1,61 @@
package proxy
import (
"regexp"
"testing"
)
func TestDefaultPatterns(t *testing.T) {
patterns := DefaultPatterns()
if len(patterns) != 4 {
t.Errorf("Expected 4 default patterns, got %d", len(patterns))
}
expectedNames := []string{"IPv4", "UUID", "AccountID", "DeviceID"}
for i, name := range expectedNames {
if patterns[i].Name != name {
t.Errorf("Expected pattern %d name %s, got %s", i, name, patterns[i].Name)
}
}
}
func TestPathPatterns_Sanitize(t *testing.T) {
patterns := DefaultPatterns()
// Need to compile them as DefaultPatterns() in its new form doesn't compile them (main.go or LoadPatterns does it)
// Wait, actually the new DefaultPatterns() I wrote doesn't compile them.
// But PathPatterns.Sanitize checks for compiled != nil.
// Let's manually compile for the test
for i := range patterns {
patterns[i].compiled = mustCompile(patterns[i].Regexp)
}
tests := []struct {
segment string
wantRepl string
}{
{"192.168.1.100", "{ip}"},
{"1234567", "{accountId}"},
{"12345", "{accountId}"},
{"12345678-1234-5678-9012-123456789012", "{uuid}"},
{"D05FB8A848E5", "{device_id}"},
{"some-other-segment", ""},
}
for _, tt := range tests {
repl, _ := patterns.Sanitize(tt.segment)
if tt.wantRepl == "" {
if repl != tt.segment {
t.Errorf("Sanitize(%q) = %q, want %q (no change)", tt.segment, repl, tt.segment)
}
} else {
if repl != tt.wantRepl {
t.Errorf("Sanitize(%q) = %q, want %q", tt.segment, repl, tt.wantRepl)
}
}
}
}
func mustCompile(re string) *regexp.Regexp {
return regexp.MustCompile(re)
}
+27 -15
View File
@@ -19,6 +19,11 @@ func TestRecorder_Record_Structure(t *testing.T) {
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
// Compile default patterns
for i := range r.Patterns {
re, _ := regexp.Compile(r.Patterns[i].Regexp)
r.Patterns[i].compiled = re
}
tests := []struct {
name string
@@ -100,11 +105,11 @@ func TestRecorder_Record_Sanitization(t *testing.T) {
r := NewRecorder(tmpDir)
// Add a custom pattern
r.Patterns = append(r.Patterns, PathPattern{
Name: "DeviceID",
Name: "CustomDeviceID",
Regexp: `^A81B\w{8}$`,
Replacement: "{deviceId}",
})
// Re-compile
// Compile all patterns
for i := range r.Patterns {
re, _ := regexp.Compile(r.Patterns[i].Regexp)
r.Patterns[i].compiled = re
@@ -124,7 +129,7 @@ func TestRecorder_Record_Sanitization(t *testing.T) {
t.Fatalf("Record failed: %v", err)
}
expectedDir := filepath.Join(tmpDir, "interactions", r.SessionID, "self", "info", "{ip}", "{deviceId}")
expectedDir := filepath.Join(tmpDir, "interactions", r.SessionID, "self", "info", "{ip}", "{device_id}")
if _, err := os.Stat(expectedDir); os.IsNotExist(err) {
t.Errorf("Expected directory %s does not exist", expectedDir)
}
@@ -137,13 +142,13 @@ func TestRecorder_Record_Sanitization(t *testing.T) {
content, _ := os.ReadFile(filepath.Join(expectedDir, files[0].Name()))
contentStr := string(content)
if !strings.Contains(contentStr, "### GET /info/{{ip}}/{{deviceId}}") {
if !strings.Contains(contentStr, "### GET /info/{{ip}}/{{device_id}}") {
t.Errorf("Expected sanitized comment in .http file, got:\n%s", contentStr)
}
if !strings.Contains(contentStr, "GET /info/{{ip}}/{{deviceId}}") {
if !strings.Contains(contentStr, "GET /info/{{ip}}/{{device_id}}") {
t.Errorf("Expected sanitized URL in .http file, got:\n%s", contentStr)
}
if !strings.Contains(contentStr, "X-Device: {{deviceId}}") {
if !strings.Contains(contentStr, "X-Device: {{device_id}}") {
t.Errorf("Expected sanitized Header in .http file, got:\n%s", contentStr)
}
}
@@ -156,22 +161,19 @@ func TestRecorder_Record_Sanitization_Account(t *testing.T) {
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
// Add AccountID pattern
r.Patterns = append(r.Patterns, PathPattern{
Name: "AccountID",
Regexp: `^\d{1,10}$`,
Replacement: "{accountId}",
})
// Re-compile
// Use default patterns which now include AccountID
r.Patterns = DefaultPatterns()
// Compile all patterns
for i := range r.Patterns {
re, _ := regexp.Compile(r.Patterns[i].Regexp)
r.Patterns[i].compiled = re
}
accountID := "1234567"
req := &http.Request{
Method: "GET",
URL: &url.URL{
Path: "/marge/accounts/12345/full",
Path: "/marge/accounts/" + accountID + "/full",
},
Header: make(http.Header),
}
@@ -200,7 +202,7 @@ func TestRecorder_Record_Sanitization_Account(t *testing.T) {
if !strings.Contains(contentStr, "GET /marge/accounts/{{accountId}}/full") {
t.Errorf("Expected sanitized URL in .http file, got:\n%s", contentStr)
}
if !strings.Contains(contentStr, "// accountId: 12345") {
if !strings.Contains(contentStr, "// accountId: "+accountID) {
t.Errorf("Expected accountId comment in .http file, got:\n%s", contentStr)
}
}
@@ -262,6 +264,11 @@ func TestRecorder_IncreasingPrefix(t *testing.T) {
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
// Compile default patterns
for i := range r.Patterns {
re, _ := regexp.Compile(r.Patterns[i].Regexp)
r.Patterns[i].compiled = re
}
req := &http.Request{
Method: "GET",
URL: &url.URL{
@@ -299,6 +306,11 @@ func TestRecorder_EnvFile(t *testing.T) {
defer os.RemoveAll(tmpDir)
r := NewRecorder(tmpDir)
// Compile default patterns
for i := range r.Patterns {
re, _ := regexp.Compile(r.Patterns[i].Regexp)
r.Patterns[i].compiled = re
}
req := &http.Request{
Method: "GET",
URL: &url.URL{
+67 -14
View File
@@ -59,6 +59,8 @@ type MigrationSummary struct {
DeviceName string `json:"device_name,omitempty"`
DeviceModel string `json:"device_model,omitempty"`
DeviceSerial string `json:"device_serial,omitempty"`
DeviceID string `json:"device_id,omitempty"`
AccountID string `json:"account_id,omitempty"`
FirmwareVersion string `json:"firmware_version,omitempty"`
CACertTrusted bool `json:"ca_cert_trusted"`
ServerHTTPSURL string `json:"server_https_url,omitempty"`
@@ -92,14 +94,15 @@ func NewManager(serverURL string, ds *datastore.DataStore, cm *certmanager.Certi
// DeviceInfoXML represents the XML structure from :8090/info
type DeviceInfoXML struct {
XMLName xml.Name `xml:"info" json:"-"`
DeviceID string `xml:"deviceID,attr" json:"deviceID"`
Name string `xml:"name" json:"name"`
Type string `xml:"type" json:"type"`
MaccAddress string `xml:"maccAddress" json:"maccAddress"`
SoftwareVer string `xml:"-" json:"softwareVersion"`
SerialNumber string `xml:"-" json:"serialNumber"`
Components []struct {
XMLName xml.Name `xml:"info" json:"-"`
DeviceID string `xml:"deviceID,attr" json:"deviceID"`
Name string `xml:"name" json:"name"`
Type string `xml:"type" json:"type"`
MaccAddress string `xml:"maccAddress" json:"maccAddress"`
SoftwareVer string `xml:"-" json:"softwareVersion"`
SerialNumber string `xml:"-" json:"serialNumber"`
MargeAccountUUID string `xml:"margeAccountUUID" json:"margeAccountUUID"`
Components []struct {
Category string `xml:"componentCategory"`
SoftwareVersion string `xml:"softwareVersion"`
SerialNumber string `xml:"serialNumber"`
@@ -260,6 +263,8 @@ func (m *Manager) populateDeviceInfo(summary *MigrationSummary, deviceIP string)
summary.DeviceName = d.Name
summary.DeviceModel = d.ProductCode
summary.DeviceSerial = d.DeviceSerialNumber
summary.DeviceID = d.DeviceID
summary.AccountID = d.AccountID
summary.FirmwareVersion = d.FirmwareVersion
break
@@ -284,6 +289,14 @@ func (m *Manager) populateDeviceInfo(summary *MigrationSummary, deviceIP string)
if infoXML.SoftwareVer != "" {
summary.FirmwareVersion = infoXML.SoftwareVer
}
if infoXML.DeviceID != "" {
summary.DeviceID = infoXML.DeviceID
}
if infoXML.MargeAccountUUID != "" {
summary.AccountID = infoXML.MargeAccountUUID
}
}
}
@@ -557,18 +570,39 @@ func (m *Manager) BackupConfigOffDevice(deviceIP string) error {
client := m.NewSSH(deviceIP)
// We need the serial number to find the right directory in DataStore
// We need the serial number and account identifier to find the right directory in DataStore
info, err := m.GetLiveDeviceInfo(deviceIP)
if err != nil {
return fmt.Errorf("failed to get device info: %w", err)
}
serial := info.SerialNumber
if serial == "" {
return fmt.Errorf("could not determine device serial number")
accountID := info.MargeAccountUUID
deviceID := info.SerialNumber
if deviceID == "" {
deviceID = info.DeviceID
}
deviceDir := m.DataStore.AccountDeviceDir("default", serial)
if deviceID == "" {
deviceID = deviceIP
}
if accountID == "" {
// Try to find account ID from existing device entries if info didn't have it
devices, _ := m.DataStore.ListAllDevices()
for i := range devices {
if devices[i].DeviceSerialNumber == info.SerialNumber || (info.DeviceID != "" && devices[i].DeviceID == info.DeviceID) {
accountID = devices[i].AccountID
break
}
}
}
if accountID == "" {
accountID = "default"
}
deviceDir := m.DataStore.AccountDeviceDir(accountID, deviceID)
if err := os.MkdirAll(deviceDir, 0755); err != nil {
return fmt.Errorf("failed to create device directory: %w", err)
}
@@ -1207,13 +1241,32 @@ func (m *Manager) SyncDeviceData(deviceIP string) error {
return fmt.Errorf("failed to get device info: %w", err)
}
accountID := "default"
accountID := ""
deviceID := info.SerialNumber
if deviceID == "" {
deviceID = deviceIP
}
if info.MargeAccountUUID != "" {
accountID = info.MargeAccountUUID
}
if accountID == "" {
// Try to find account ID from existing device entries if info didn't have it
devices, _ := m.DataStore.ListAllDevices()
for i := range devices {
if devices[i].DeviceSerialNumber == info.SerialNumber || devices[i].DeviceID == info.DeviceID {
accountID = devices[i].AccountID
break
}
}
}
if accountID == "" {
accountID = "default"
}
// 2. Fetch Presets from :8090
m.syncPresets(deviceIP, accountID, deviceID)
+3 -2
View File
@@ -776,11 +776,12 @@ func TestBackupConfigOffDevice(t *testing.T) {
m := NewManager("http://localhost:8000", ds, nil)
serial := "08DF1F0BA325"
accountID := "3230304"
// Mock info server
infoServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprintf(w, `<info deviceID="%s"><name>Test</name><components><component><componentCategory>SCM</componentCategory><serialNumber>%s</serialNumber></component></components></info>`, serial, serial)
fmt.Fprintf(w, `<info deviceID="%s"><name>Test</name><margeAccountUUID>%s</margeAccountUUID><components><component><componentCategory>SCM</componentCategory><serialNumber>%s</serialNumber></component></components></info>`, serial, accountID, serial)
}))
defer infoServer.Close()
@@ -808,7 +809,7 @@ func TestBackupConfigOffDevice(t *testing.T) {
}
// Verify files were created in datastore
deviceDir := m.DataStore.AccountDeviceDir("default", serial)
deviceDir := m.DataStore.AccountDeviceDir(accountID, serial)
configPath := filepath.Join(deviceDir, "SoundTouchSdkPrivateCfg.xml.bak")
hostsPath := filepath.Join(deviceDir, "hosts.bak")
+7 -6
View File
@@ -166,19 +166,20 @@ Then restart the service.
# Updating to a New Version
To upgrade:
To upgrade, simply run the installer with the desired version as an argument:
```bash
sudo VERSION=vX.Y.Z bash install-soundtouch-service.sh
sudo bash install.sh vX.Y.Z
```
The script will:
* Download the new binary
* Overwrite the old one
* Restart the service
* Automatically fetch the latest version of the installer script for that release
* Download the new service binary
* Backup the old binary to `.old`
* Overwrite the binary and restart the service
No need to reconfigure anything.
No need to reconfigure anything; your existing `.env` file and data will be preserved.
---
+60 -7
View File
@@ -4,7 +4,10 @@ set -euo pipefail
# ==============================================================================
# Bose-SoundTouch soundtouch-service installer (systemd, headless)
#
# Example usage (override defaults via env vars):
# Usage:
# sudo bash install.sh [vX.Y.Z]
#
# Examples (override defaults via env vars):
#
# sudo \
# VERSION=v0.17.0 \
@@ -12,11 +15,10 @@ set -euo pipefail
# HTTP_PORT=80 \
# HTTPS_PORT=443 \
# DATA_DIR=/var/lib/soundtouch-service \
# LOG_PROXY_BODY=false \
# REDACT_PROXY_LOGS=true \
# RECORD_INTERACTIONS=true \
# DISCOVERY_INTERVAL=5m \
# bash install-soundtouch-service.sh
# bash install.sh
#
# Or with a version argument to perform an update:
# sudo bash install.sh v0.18.1
#
# Notes:
# - This script downloads a release binary for your CPU (auto-detects armv7/arm64/amd64).
@@ -26,7 +28,11 @@ set -euo pipefail
# - Safe to re-run; it will update binary/config/unit and restart the service.
# ==============================================================================
VERSION="${VERSION:-v0.17.0}"
VERSION="${1:-${VERSION:-v0.18.1}}"
# Normalize version prefix
if [[ ! "$VERSION" =~ ^v ]]; then
VERSION="v${VERSION}"
fi
SERVICE_NAME="${SERVICE_NAME:-soundtouch-service}"
BIN_PATH="${BIN_PATH:-/usr/local/bin/soundtouch-service}"
@@ -56,6 +62,10 @@ DISCOVERY_INTERVAL="${DISCOVERY_INTERVAL:-5m}"
# ARCH_ASSET=linux-armv7|linux-arm64|linux-amd64
ARCH_ASSET="${ARCH_ASSET:-}"
# Internal variables
SCRIPT_PATH="$(realpath "$0" 2>/dev/null || echo "$0")"
IS_SELF_UPDATE="${IS_SELF_UPDATE:-false}"
log() { printf "\n==> %s\n" "$*"; }
die() { echo "ERROR: $*" >&2; exit 1; }
@@ -157,6 +167,47 @@ download_binary() {
log "Installed binary to ${BIN_PATH}"
}
self_update() {
# If we are already a self-update re-exec, don't do it again
if [[ "$IS_SELF_UPDATE" == "true" ]]; then
return
fi
local url="https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/${VERSION}/scripts/raspberry-pi/install.sh"
local tmp_script="/tmp/soundtouch-install-${VERSION}.sh"
log "Checking for installer updates for ${VERSION}..."
log "URL: ${url}"
if command -v curl >/dev/null 2>&1; then
if ! curl -fsSL -o "${tmp_script}" "${url}"; then
log "⚠️ Could not fetch installer for ${VERSION}, continuing with current script."
return
fi
else
if ! wget -qO "${tmp_script}" "${url}"; then
log "⚠️ Could not fetch installer for ${VERSION}, continuing with current script."
return
fi
fi
# Compare scripts to see if we actually need to re-exec
if diff -q "${SCRIPT_PATH}" "${tmp_script}" >/dev/null 2>&1; then
log "Installer is already up to date."
rm -f "${tmp_script}"
return
fi
log "Newer installer found for ${VERSION}. Re-executing..."
chmod +x "${tmp_script}"
# Export current env vars to the new script
export IS_SELF_UPDATE="true"
export VERSION HOSTNAME_FQDN HTTP_PORT HTTPS_PORT DATA_DIR BIN_PATH CONFIG_DIR ENV_FILE SERVICE_USER SERVICE_GROUP
exec "${tmp_script}" "$@"
}
write_env_file() {
log "Writing env file: ${ENV_FILE}"
cat > "${ENV_FILE}" <<EOF
@@ -282,6 +333,8 @@ main() {
apt_install_if_missing curl
fi
self_update "$@"
ensure_user_group
ensure_dirs
download_binary