mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-31 14:57:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e438db05d9 | ||
|
|
8c02a009dc | ||
|
|
f20cfcb319 | ||
|
|
a453059d6d | ||
|
|
505e6dd760 | ||
|
|
735187cae8 | ||
|
|
e8622cc382 | ||
|
|
c59052bdb4 | ||
|
|
15a6c4b0a0 | ||
|
|
ae3a3765db | ||
|
|
59019cf55c | ||
|
|
5e612e57ec | ||
|
|
02026a9f3a | ||
|
|
aaf067088a | ||
|
|
358ea18138 | ||
|
|
0c5c1803a5 | ||
|
|
cdf80a793e | ||
|
|
b511e052e2 | ||
|
|
b7197a8679 | ||
|
|
5bfc24b7fb | ||
|
|
1e61adbb46 | ||
|
|
b8ab4b5723 | ||
|
|
5da7e001b2 | ||
|
|
5269c05e56 | ||
|
|
d7a15c4dbe | ||
|
|
701889076d | ||
|
|
3acc983183 |
@@ -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@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
|
||||
@@ -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
|
||||
|
||||
|
||||
+4
-4
@@ -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.
|
||||
|
||||
@@ -23,6 +23,7 @@ A comprehensive solution for controlling and preserving Bose SoundTouch devices,
|
||||
- 🔧 **Service Migration**: Migrate devices to use local services instead of Bose cloud
|
||||
- 📊 **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 +62,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/CLI-REFERENCE.md](docs/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)
|
||||
|
||||
@@ -69,21 +70,25 @@ The `soundtouch-service` is a local server that emulates Bose's cloud services.
|
||||
|
||||
#### Key Features:
|
||||
- **🏠 Local Emulation**: BMX and Marge service implementation
|
||||
- **🔌 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
|
||||
- **💾 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
|
||||
# Start the service
|
||||
soundtouch-service
|
||||
```
|
||||
Open `http://localhost:8000` in your browser to manage your devices.
|
||||
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/CLOUD-SHUTDOWN-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/SOUNDTOUCH-SERVICE.md](docs/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](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html).
|
||||
|
||||
### Library Usage
|
||||
|
||||
@@ -376,19 +381,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
|
||||
|
||||
@@ -523,13 +528,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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -136,21 +137,96 @@ func main() {
|
||||
Action: func(c *cli.Context) error {
|
||||
config := loadConfig(c)
|
||||
ds := initDataStore(config.dataDir)
|
||||
|
||||
// Load settings from datastore
|
||||
persisted, err := ds.GetSettings()
|
||||
|
||||
settingsExist := err == nil && persisted.ServerURL != ""
|
||||
if persisted.ServerURL != "" {
|
||||
config.serverURL = persisted.ServerURL
|
||||
}
|
||||
|
||||
if persisted.ProxyURL != "" {
|
||||
config.targetURL = persisted.ProxyURL
|
||||
}
|
||||
|
||||
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.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.DiscoveryEnabled = true
|
||||
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 == "" {
|
||||
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.SetHTTPServerURL(config.httpsServerURL)
|
||||
server.SetVersionInfo(version, commit, date)
|
||||
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
|
||||
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)
|
||||
@@ -160,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)
|
||||
|
||||
@@ -364,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, enabled := server.GetDiscoverySettings()
|
||||
if enabled {
|
||||
server.DiscoverDevices(context.Background())
|
||||
}
|
||||
|
||||
time.Sleep(currentInterval)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -377,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)
|
||||
@@ -388,6 +469,7 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
|
||||
|
||||
r.Get("/media/*", server.HandleMedia())
|
||||
r.Get("/web/*", server.HandleWeb())
|
||||
r.Get("/docs/*", server.HandleDocs)
|
||||
|
||||
r.Route("/bmx", func(r chi.Router) {
|
||||
r.Get("/registry/v1/services", server.HandleBMXRegistry)
|
||||
@@ -422,9 +504,11 @@ 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)
|
||||
r.Post("/settings", server.HandleUpdateSettings)
|
||||
r.Get("/info/{deviceIP}", server.HandleGetDeviceInfo)
|
||||
r.Get("/summary/{deviceIP}", server.HandleGetMigrationSummary)
|
||||
r.Post("/migrate/{deviceIP}", server.HandleMigrateDevice)
|
||||
@@ -440,6 +524,12 @@ 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("/interaction-stats", server.HandleGetInteractionStats)
|
||||
r.Get("/interactions", server.HandleListInteractions)
|
||||
r.Get("/interaction-content", server.HandleGetInteractionContent)
|
||||
r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession)
|
||||
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
|
||||
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
accounts/
|
||||
certs/
|
||||
default/
|
||||
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}"
|
||||
}
|
||||
]
|
||||
+2
-3
@@ -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.
|
||||
|
||||
@@ -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! 🎵
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Table of Contents
|
||||
|
||||
* [Introduction](README.md)
|
||||
|
||||
## User Guides
|
||||
* [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](guides/SOUNDTOUCH-SERVICE.md)
|
||||
* [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)
|
||||
* [System Endpoints](reference/SYSTEM-ENDPOINTS.md)
|
||||
* [Speaker Endpoint](reference/SPEAKER-ENDPOINT.md)
|
||||
* [WebSocket Events](reference/WEBSOCKET-EVENTS.md)
|
||||
* [Discovery](reference/DISCOVERY.md)
|
||||
* [Zone Management](reference/ZONE-MANAGEMENT.md)
|
||||
* [Preset Management](reference/PRESET-MANAGEMENT.md)
|
||||
* [Source Selection](reference/SOURCE-SELECTION.md)
|
||||
* [Volume Controls](reference/VOLUME-CONTROLS.md)
|
||||
* [Bass Controls](reference/BASS-CONTROLS.md)
|
||||
* [Key Controls](reference/KEY-CONTROLS.md)
|
||||
* [Feature Mapping](reference/FEATURE-MAPPING.md)
|
||||
|
||||
## Analysis & Research
|
||||
* [API Coverage Analysis](analysis/API-COVERAGE.md)
|
||||
* [Supported URLs](analysis/SUPPORTED-URLS.md)
|
||||
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
|
||||
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
|
||||
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
|
||||
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
|
||||
|
||||
## Appendix (Other Documents)
|
||||
* [API Navigation Reference](API-NAVIGATION-REFERENCE.md)
|
||||
* [Claude Instructions](CLAUDE.md)
|
||||
* [Content Selection Implementation](CONTENT-SELECTION-IMPLEMENTATION.md)
|
||||
* [Device Customization Setup](DEVICE-CUSTOMIZATION-SETUP.md)
|
||||
* [Device Logging](DEVICE-LOGGING.md)
|
||||
* [Feature History](FEATURE_HISTORY.md)
|
||||
* [Host/Port Parsing](HOST-PORT-PARSING.md)
|
||||
* [Manual Network Discovery](MANUAL-NETWORK-DISCOVERY.md)
|
||||
* [Navigation Guide](NAVIGATION-GUIDE.md)
|
||||
* [Official API Verification](OFFICIAL-API-VERIFICATION.md)
|
||||
* [Preset Quickstart](PRESET-QUICKSTART.md)
|
||||
* [Project Patterns](PROJECT-PATTERNS.md)
|
||||
* [Service Availability Implementation](SERVICE-AVAILABILITY-IMPLEMENTATION.md)
|
||||
* [SoundTouch Service Announcement](SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md)
|
||||
* [Undocumented Community Features](UNDOCUMENTED-COMMUNITY-FEATURES.md)
|
||||
* [Unimplemented Endpoints](UNIMPLEMENTED-ENDPOINTS.md)
|
||||
* [Preset Store](preset-store.md)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,41 @@
|
||||
### Professional Migration & Safety Guide
|
||||
|
||||
Starting a migration on real hardware requires a "Safety First" approach. This guide outlines the safety features implemented in the `soundtouch-service` and provides a checklist for a successful migration.
|
||||
|
||||
#### 🛠 Technical Safety Enhancements
|
||||
|
||||
The following features are built into the `soundtouch-service` to ensure stability and easy rollbacks:
|
||||
|
||||
1. **Off-Device Backups**: Before any migration starts, the service automatically fetches the original `SoundTouchSdkPrivateCfg.xml` and `/etc/hosts` from your speaker and saves them locally in your `data/default/devices/<SERIAL>/` directory. This ensures you have a recovery path even if the speaker's filesystem becomes inaccessible.
|
||||
2. **Pre-flight Write Verification**: The migration process includes a mandatory check for SSH write access (`rw`) before attempting any modifications. This prevents "half-baked" migrations where a script might fail halfway through due to a read-only filesystem.
|
||||
3. **Automatic Safety on Sync**: Running a "Sync" in the Web UI or CLI automatically triggers an off-device backup, making it the perfect first step for any new device discovery.
|
||||
|
||||
#### 📋 Professional Migration Checklist
|
||||
|
||||
Before you proceed with the actual migration, follow these steps:
|
||||
|
||||
1. **Enable SSH Access (Prerequisite)**: This toolkit requires SSH access to your speakers, which is not enabled by default.
|
||||
- Create an empty file named `remote_services` on a USB stick.
|
||||
- Insert the USB stick into the SoundTouch speaker's **SERVICE** port.
|
||||
- Reboot the speaker (unplug and replug).
|
||||
- The speaker will now allow SSH connections as `root` with no password.
|
||||
- **Verify**: Run `ssh -oHostKeyAlgorithms=+ssh-rsa root@<SPEAKER-IP>` to confirm access. (Note: older devices may require enabling `ssh-rsa` support).
|
||||
2. **Network Isolation (Optional but Recommended)**: Ensure the device is on a stable wired connection if possible, or a dedicated 2.4GHz SSID to avoid drops during SSH operations.
|
||||
3. **Initial Discovery & Sync**:
|
||||
- Run `soundtouch-cli discover devices` to ensure connectivity.
|
||||
- Use the Web UI or CLI to "Sync" the device. This will automatically backup your presets and system configuration files to your local server.
|
||||
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.
|
||||
6. **Monitor Logs**: Run the `soundtouch-service` with `DEBUG` or `INFO` logging to see the step-by-step progress of the migration.
|
||||
|
||||
#### 🔄 Rollback Strategy
|
||||
|
||||
If something goes wrong or you want to return to the original Bose cloud services:
|
||||
|
||||
* **Standard Revert**: Use the "Revert Migration" button in the Web UI or the corresponding CLI command. This restores the `.original` files created on the device.
|
||||
* **Emergency Recovery**: If the device is unreachable via the UI but SSH still works, you can manually restore the files from your local `data/` directory using `scp` or the backups created on-device (`.original`).
|
||||
* **Factory Reset**: As a last resort, Bose SoundTouch devices can be factory reset (usually by holding '1' and 'Volume Down' while plugging in). This will wipe all settings and return the device to the stock firmware configuration (the firmware itself remains at the current version, but configurations are reset).
|
||||
|
||||
By using the built-in off-device backups and pre-flight checks, the risk of "bricking" or losing configuration during the transition is significantly reduced.
|
||||
@@ -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.
|
||||
@@ -136,7 +136,17 @@ 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
|
||||
|
||||
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 |
|
||||
|------------------------------------|----------------------------|--------------------------------------------------|---------------------------|
|
||||
@@ -151,6 +161,7 @@ The service can be configured via environment variables or command-line flags:
|
||||
| `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
|
||||
|
||||
@@ -364,6 +375,13 @@ 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.
|
||||
|
||||
### Usage Tips
|
||||
|
||||
1. **First Time Setup**: The interface will guide you through initial device discovery
|
||||
@@ -382,6 +400,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
|
||||
|
||||
@@ -520,6 +539,26 @@ find data/stats/ -name "*.json" -mtime +90 -delete
|
||||
- `POST /setup/migrate/{deviceIP}`: 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.
|
||||
|
||||
### Emulated Services
|
||||
- `/bmx/registry/v1/services`: BMX service registry.
|
||||
- `/bmx/tunein/v1/*`: TuneIn radio emulation.
|
||||
@@ -40,16 +40,20 @@ Open your web browser and navigate to the service's web interface:
|
||||
To migrate your speakers, the service needs SSH access. You can enable it by:
|
||||
1. Creating an empty file named `remote_services` on a USB stick.
|
||||
2. Inserting the USB stick into the SoundTouch speaker's service port.
|
||||
3. Rebooting the speaker.
|
||||
3. Rebooting the speaker (unplug/replug).
|
||||
|
||||
**Verify SSH Access:**
|
||||
- Confirm the device responds to SSH without a password: `ssh -oHostKeyAlgorithms=+ssh-rsa root@<IP>`
|
||||
- Or use the **Migration** tab in the Web UI to see if the device shows a "✅ Success" status for SSH.
|
||||
Once enabled, you can log in as `root` (no password).
|
||||
|
||||
#### 4. Discover and Sync Device Data
|
||||
The web interface handles the entire process in a guided flow across four tabs:
|
||||
#### 4. Setup Through the Web UI
|
||||
The web interface handles the entire process in a guided flow. Before proceeding, we strongly recommend reviewing the [Migration & Safety Guide](MIGRATION-SAFETY.md).
|
||||
|
||||
* **Step 1: Devices**: The service automatically scans for SoundTouch devices on your network. If a device is not found, you can manually add its IP address.
|
||||
* **Step 2: Data Sync**: Select your device and click "Start Sync". This will automatically fetch your presets, recents, and configured sources from the speaker and store them in the local `data/` directory.
|
||||
* **Step 3: Migration**: Choose your redirection method (XML Recommended) and click "Confirm Migration & Reboot".
|
||||
* **Step 4: Settings**: Configure global server URLs and proxy behavior (logging, redaction).
|
||||
* **Step 1: Settings**: Configure your server's IP or domain. This ensures the speakers know where to find the local services.
|
||||
* **Step 2: Devices**: The service automatically scans for SoundTouch devices on your network. If a device is not found, you can manually add its IP address.
|
||||
* **Step 3: Data Sync**: Select your device and click "Start Sync". This will automatically fetch your presets, recents, and configured sources from the speaker and store them in the local `data/` directory.
|
||||
* **Step 4: Migration**: Choose your redirection method (XML Recommended) and click "Confirm Migration". After the migration, reboot your speaker to apply the changes.
|
||||
|
||||
#### 5. Verify Your Local Data
|
||||
Once migrated, your speaker will use the data captured during the Sync step.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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,6 +6,7 @@ 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/russross/blackfriday/v2 v2.1.0
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.47.0
|
||||
)
|
||||
@@ -13,7 +14,6 @@ require (
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -706,6 +696,63 @@ func (ds *DataStore) GetETagForAccount(account, device string) int64 {
|
||||
return maxETag
|
||||
}
|
||||
|
||||
// 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"`
|
||||
DiscoveryInterval string `json:"discovery_interval,omitempty"`
|
||||
DiscoveryEnabled bool `json:"discovery_enabled"`
|
||||
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")
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
ProxyURL: "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,71 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDocsConsistency(t *testing.T) {
|
||||
// Root of the project relative to this test file
|
||||
// The test runs in the directory of the package
|
||||
projectRoot := "../../.."
|
||||
docsDir := filepath.Join(projectRoot, "docs")
|
||||
summaryPath := filepath.Join(docsDir, "SUMMARY.md")
|
||||
|
||||
summaryContent, err := os.ReadFile(summaryPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read SUMMARY.md: %v", err)
|
||||
}
|
||||
|
||||
summaryText := string(summaryContent)
|
||||
|
||||
// List of directories to check
|
||||
dirsToCheck := []string{".", "guides", "reference", "analysis"}
|
||||
|
||||
for _, dir := range dirsToCheck {
|
||||
dirPath := filepath.Join(docsDir, dir)
|
||||
err := filepath.WalkDir(dirPath, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
// Don't recurse into subdirectories if we are checking the root,
|
||||
// as they are handled separately or ignored (like archive)
|
||||
if dir == "." && path != dirPath {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(d.Name(), ".md") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip SUMMARY.md itself
|
||||
if d.Name() == "SUMMARY.md" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get relative path from docs/
|
||||
relPath, err := filepath.Rel(docsDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if this file is linked in SUMMARY.md
|
||||
// We look for [Label](relPath)
|
||||
linkPattern := "(" + relPath + ")"
|
||||
if !strings.Contains(summaryText, linkPattern) {
|
||||
t.Errorf("Documentation file %s is not linked in docs/SUMMARY.md", relPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Error walking directory %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/russross/blackfriday/v2"
|
||||
)
|
||||
|
||||
// HandleDocs returns a handler for serving documentation files as HTML.
|
||||
func (s *Server) HandleDocs(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/docs")
|
||||
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
if path == "" {
|
||||
path = "guides/SURVIVAL-GUIDE.md"
|
||||
}
|
||||
|
||||
// Ensure we only serve files from the docs directory
|
||||
filePath := filepath.Join("docs", path)
|
||||
if !strings.HasPrefix(filepath.Clean(filePath), "docs") {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
http.Error(w, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Load sidebar (SUMMARY.md)
|
||||
summaryContent, _ := os.ReadFile(filepath.Join("docs", "SUMMARY.md"))
|
||||
|
||||
sidebar := ""
|
||||
if len(summaryContent) > 0 {
|
||||
// Render summary to HTML
|
||||
sidebar = string(blackfriday.Run(summaryContent))
|
||||
// Adjust links in sidebar to be relative to /docs/
|
||||
sidebar = strings.ReplaceAll(sidebar, "href=\"guides/", "href=\"/docs/guides/")
|
||||
sidebar = strings.ReplaceAll(sidebar, "href=\"reference/", "href=\"/docs/reference/")
|
||||
sidebar = strings.ReplaceAll(sidebar, "href=\"analysis/", "href=\"/docs/analysis/")
|
||||
// Fix relative links that don't have a directory prefix (root docs)
|
||||
// We look for href="filename.md" and replace with href="/docs/filename.md"
|
||||
// This avoids manual listing of every file.
|
||||
sidebar = s.fixSidebarLinks(sidebar)
|
||||
}
|
||||
|
||||
// Render markdown to HTML
|
||||
output := blackfriday.Run(content)
|
||||
|
||||
// Wrap in a documentation template with sidebar
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>%s - Bose SoundTouch Toolkit Docs</title>
|
||||
<link rel="icon" href="/media/favicon-braille.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/web/css/style.css">
|
||||
<style>
|
||||
body { margin: 0; padding: 0; display: flex; font-family: sans-serif; height: 100vh; overflow: hidden; }
|
||||
.sidebar { width: 300px; background: #f8f9fa; border-right: 1px solid #dee2e6; padding: 20px; overflow-y: auto; flex-shrink: 0; }
|
||||
.content-area { flex-grow: 1; overflow-y: auto; padding: 40px; }
|
||||
.markdown-body { max-width: 800px; margin: 0 auto; line-height: 1.6; color: #333; }
|
||||
h1, h2, h3 { color: #2196F3; }
|
||||
pre { background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto; }
|
||||
code { font-family: monospace; background: #eee; padding: 2px 4px; border-radius: 3px; }
|
||||
pre code { background: none; padding: 0; }
|
||||
a { color: #2196F3; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.back-link { margin-bottom: 20px; display: block; font-weight: bold; }
|
||||
.sidebar h2 { font-size: 1.1em; margin-top: 20px; color: #666; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.sidebar ul { list-style: none; padding: 0; }
|
||||
.sidebar li { margin-bottom: 8px; }
|
||||
.sidebar a { color: #444; font-size: 0.95em; }
|
||||
.sidebar a:hover { color: #2196F3; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sidebar">
|
||||
<a href="/" class="back-link">← Back to Toolkit</a>
|
||||
%s
|
||||
</div>
|
||||
<div class="content-area">
|
||||
<div class="markdown-body">
|
||||
%s
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`, path, sidebar, output)
|
||||
}
|
||||
|
||||
// fixSidebarLinks ensures that relative links in the SUMMARY.md (sidebar)
|
||||
// are correctly prefixed with /docs/ for the web UI.
|
||||
func (s *Server) fixSidebarLinks(sidebar string) string {
|
||||
// Root links like [Label](file.md) become href="file.md"
|
||||
// We want href="/docs/file.md", but only if it doesn't already start with /docs/
|
||||
// and isn't an external link.
|
||||
// Since blackfriday renders [Label](file.md) as <a href="file.md">
|
||||
|
||||
// A simple but effective way is to use a regex or just check for common patterns.
|
||||
// We already handled subdirectories. Now we handle files in the root of docs/
|
||||
|
||||
// We'll look for href="filename.md" where filename doesn't contain a slash
|
||||
// and isn't already prefixed.
|
||||
|
||||
// Since we know our doc files always end in .md, we can look for that.
|
||||
lines := strings.Split(sidebar, "\n")
|
||||
for i, line := range lines {
|
||||
if strings.Contains(line, "href=\"") && !strings.Contains(line, "href=\"/docs/") && !strings.Contains(line, "://") {
|
||||
// Extract filename
|
||||
start := strings.Index(line, "href=\"") + 6
|
||||
end := strings.Index(line[start:], "\"") + start
|
||||
filename := line[start:end]
|
||||
|
||||
if strings.HasSuffix(filename, ".md") && !strings.Contains(filename, "/") {
|
||||
lines[i] = strings.ReplaceAll(line, "href=\""+filename+"\"", "href=\"/docs/"+filename+"\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -3,10 +3,14 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"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 +65,7 @@ func (s *Server) HandleAddManualDevice(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
s.handleDiscoveredDevice(d)
|
||||
s.mergeOverlappingDevices()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -89,19 +94,138 @@ 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, proxyURL, httpsServerURL := s.serverURL, s.proxyURL, s.httpsServerURL
|
||||
discoveryInterval := s.discoveryInterval.String()
|
||||
discoveryEnabled := s.discoveryEnabled
|
||||
s.mu.RUnlock()
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"server_url": serverURL,
|
||||
"proxy_url": proxyURL,
|
||||
"https_server_url": httpsServerURL,
|
||||
"discovery_interval": discoveryInterval,
|
||||
"discovery_enabled": discoveryEnabled,
|
||||
}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 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"`
|
||||
DiscoveryInterval string `json:"discovery_interval"`
|
||||
DiscoveryEnabled bool `json:"discovery_enabled"`
|
||||
}
|
||||
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.discoveryEnabled = settings.DiscoveryEnabled
|
||||
|
||||
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,
|
||||
ProxyURL: s.proxyURL,
|
||||
HTTPServerURL: currentHTTPS,
|
||||
RedactLogs: currentRedact,
|
||||
LogBodies: currentLogBody,
|
||||
RecordInteractions: currentRecord,
|
||||
DiscoveryInterval: s.discoveryInterval.String(),
|
||||
DiscoveryEnabled: s.discoveryEnabled,
|
||||
})
|
||||
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")
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGetDeviceInfo returns live information for a device.
|
||||
func (s *Server) HandleGetDeviceInfo(w http.ResponseWriter, r *http.Request) {
|
||||
deviceIP := chi.URLParam(r, "deviceIP")
|
||||
@@ -389,10 +513,12 @@ 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")
|
||||
|
||||
redact, logBody, record := s.GetProxySettings()
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]bool{
|
||||
"redact": s.proxyRedact,
|
||||
"log_body": s.proxyLogBody,
|
||||
"record": s.recordEnabled,
|
||||
"redact": redact,
|
||||
"log_body": logBody,
|
||||
"record": record,
|
||||
}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -426,10 +552,35 @@ func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.proxyRedact = settings.Redact
|
||||
s.proxyLogBody = settings.LogBody
|
||||
s.recordEnabled = settings.Record
|
||||
|
||||
// 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()
|
||||
discoveryEnabled := s.discoveryEnabled
|
||||
|
||||
log.Printf("Saving updated proxy settings to %s/settings.json", s.ds.DataDir)
|
||||
err := s.ds.SaveSettings(datastore.Settings{
|
||||
ServerURL: serverURL,
|
||||
ProxyURL: proxyURL,
|
||||
HTTPServerURL: httpsServerURL,
|
||||
RedactLogs: s.proxyRedact,
|
||||
LogBodies: s.proxyLogBody,
|
||||
RecordInteractions: s.recordEnabled,
|
||||
DiscoveryInterval: discoveryInterval,
|
||||
DiscoveryEnabled: discoveryEnabled,
|
||||
})
|
||||
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")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Proxy settings updated"}); err != nil {
|
||||
@@ -573,3 +724,136 @@ 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}`))
|
||||
}
|
||||
|
||||
@@ -15,7 +15,16 @@ import (
|
||||
)
|
||||
|
||||
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 +95,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",
|
||||
"proxy_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, proxyURL=%s", sURL, pURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationAndCA(t *testing.T) {
|
||||
@@ -212,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) {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
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) {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -47,6 +47,10 @@ 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)
|
||||
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
|
||||
r.Post("/ensure-remote-services/{deviceIP}", server.HandleEnsureRemoteServices)
|
||||
|
||||
+213
-27
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
@@ -14,30 +15,90 @@ import (
|
||||
|
||||
// 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
|
||||
proxyURL string
|
||||
httpsServerURL string
|
||||
discovering bool
|
||||
proxyRedact bool
|
||||
proxyLogBody bool
|
||||
recordEnabled bool
|
||||
discoveryInterval time.Duration
|
||||
discoveryEnabled 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, enabled bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.discoveryInterval = interval
|
||||
s.discoveryEnabled = enabled
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// SetRecorder sets the recorder for the server.
|
||||
func (s *Server) SetRecorder(r *proxy.Recorder) {
|
||||
s.recorder = r
|
||||
@@ -45,9 +106,28 @@ func (s *Server) SetRecorder(r *proxy.Recorder) {
|
||||
|
||||
// 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.proxyURL, s.httpsServerURL
|
||||
}
|
||||
|
||||
// GetProxySettings returns the current proxy settings.
|
||||
func (s *Server) GetProxySettings() (bool, bool, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.proxyRedact, s.proxyLogBody, s.recordEnabled
|
||||
}
|
||||
|
||||
// DiscoverDevices starts a background device discovery process.
|
||||
//
|
||||
//nolint:contextcheck
|
||||
@@ -78,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
|
||||
@@ -103,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,
|
||||
@@ -116,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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -4,7 +4,7 @@ th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background-color: #f2f2f2; }
|
||||
button { padding: 5px 10px; cursor: pointer; }
|
||||
.status { margin-top: 10px; padding: 10px; border: 1px solid #ccc; display: none; }
|
||||
.summary-box { margin-top: 20px; padding: 15px; border: 1px solid #aaa; background-color: #f9f9f9; display: none; }
|
||||
.summary-box { margin-top: 20px; padding: 15px; border: 1px solid #aaa; background-color: #f9f9f9; }
|
||||
pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px; }
|
||||
.diff-container { display: flex; gap: 10px; }
|
||||
.diff-pane { flex: 1; min-width: 0; }
|
||||
@@ -34,3 +34,71 @@ pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px;
|
||||
padding: 5px;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
/* Guide & Overview styles */
|
||||
.guide-steps li {
|
||||
margin-bottom: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.guide-steps strong {
|
||||
color: #2196F3;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.info-box {
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.prerequisite-box {
|
||||
background-color: #fffde7;
|
||||
border-left: 4px solid #fff176;
|
||||
}
|
||||
.safety-box {
|
||||
background-color: #e3f2fd;
|
||||
border-left: 4px solid #2196f3;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.info-box a {
|
||||
color: #0d47a1;
|
||||
font-weight: bold;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
font-weight: bold;
|
||||
}
|
||||
.stats-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.stats-list li {
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.stats-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.category-self { background-color: #e3f2fd; color: #0d47a1; }
|
||||
.category-upstream { background-color: #f3e5f5; color: #7b1fa2; }
|
||||
.status-success { background-color: #e8f5e9; color: #2e7d32; }
|
||||
.status-error { background-color: #ffebee; color: #c62828; }
|
||||
|
||||
@@ -11,14 +11,109 @@
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab-buttons">
|
||||
<button class="tab-btn active" onclick="openTab(event, 'tab-devices')">1. Devices</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-sync')">2. Data Sync</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-migration')">3. Migration</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-settings')">4. Settings</button>
|
||||
<button class="tab-btn active" onclick="openTab(event, 'tab-overview')">Overview</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-settings')">1. Settings</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-devices')">2. Devices</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-sync')">3. Data Sync</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-migration')">4. Migration</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-interactions')">5. Interactions</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 1: Devices -->
|
||||
<div id="tab-devices" class="tab-content active">
|
||||
<!-- Tab 0: Overview -->
|
||||
<div id="tab-overview" class="tab-content active">
|
||||
<h2>Welcome to Bose SoundTouch Toolkit</h2>
|
||||
<p>This toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026. It emulates the necessary cloud services locally on your network.</p>
|
||||
|
||||
<h3>Migration Process at a Glance</h3>
|
||||
<div class="info-box prerequisite-box">
|
||||
<strong>🔌 Prerequisite: Enable SSH</strong><br>
|
||||
Migration requires SSH access. To enable it:
|
||||
<ol style="margin-top: 5px; margin-bottom: 5px;">
|
||||
<li>Create an empty file named <code>remote_services</code> on a USB stick.</li>
|
||||
<li>Insert it into the speaker's <strong>SERVICE</strong> port and reboot the speaker.</li>
|
||||
</ol>
|
||||
<strong>Verify connection:</strong>
|
||||
<ul style="margin-top: 5px; margin-bottom: 0; padding-left: 20px;">
|
||||
<li>Use the <strong>Migration</strong> tab to select your device and verify that <em>SSH Connection</em> shows ✅ Success.</li>
|
||||
<li>Or manually: <code>ssh -oHostKeyAlgorithms=+ssh-rsa root@<SPEAKER-IP></code> (no password).</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ol class="guide-steps">
|
||||
<li>
|
||||
<strong>Settings:</strong> Review the <strong>Settings</strong> tab. Ensure the "Target Domain" and "Proxy Domain" use an IP address or domain name that is <strong>accessible from your speakers</strong> (usually the IP of this server on your local network).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Discovery:</strong> Go to the <strong>Devices</strong> tab to find your speakers on the network.
|
||||
Ensure your speakers are powered on and connected to the same network.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Data Sync:</strong> In the <strong>Data Sync</strong> tab, fetch your current presets, recents, and sources.
|
||||
This step is critical to ensure your local service has all your personalized data before you disconnect from the Bose cloud.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Migration:</strong> In the <strong>Migration</strong> tab, redirect your speaker to this local service.
|
||||
We recommend the <strong>XML Configuration</strong> method as it is surgical and easily reversible.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Verification:</strong> After migration and reboot, your speaker will communicate with this toolkit instead of Bose servers.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="info-box safety-box">
|
||||
<strong>⚠️ Safety First:</strong> Before starting any migration, please read our
|
||||
<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="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>
|
||||
|
||||
<!-- Tab 1: Settings -->
|
||||
<div id="tab-settings" class="tab-content">
|
||||
<h2>System Settings</h2>
|
||||
<p style="font-size: 0.9em; color: #555; margin-bottom: 20px;">
|
||||
<strong>Note:</strong> These URLs must be <strong>accessible from your SoundTouch devices</strong>.
|
||||
Use the IP address of this server on your local network (e.g., <code>http://192.168.1.100:8000</code>)
|
||||
rather than <code>localhost</code>.
|
||||
</p>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label for="target-domain">Target Domain:</label>
|
||||
<input type="text" id="target-domain" placeholder="http://192.168.x.x:8000" style="width: 300px;">
|
||||
<span style="font-size: 0.8em; color: #666;">(Standard services URL)</span>
|
||||
</div>
|
||||
<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 - 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-enabled"> Enable Automated Discovery</label>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<button onclick="updateSettings()">Save Settings</button>
|
||||
<span id="settings-status" style="margin-left: 10px; font-size: 0.9em;"></span>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<strong>Proxy Logging:</strong>
|
||||
<div style="margin-top: 5px;">
|
||||
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"> Redact Sensitive Headers</label>
|
||||
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"> Log Bodies</label>
|
||||
<label style="display: block; margin-bottom: 5px;">
|
||||
<input type="checkbox" id="proxy-record" onchange="updateProxySettings()"> Record Interactions
|
||||
<span style="font-size: 0.85em; color: #666; margin-left: 5px;">(View in <strong>5. Interactions</strong> tab)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Devices -->
|
||||
<div id="tab-devices" class="tab-content">
|
||||
<h2>Known Devices <span id="discovery-indicator" style="font-size: 0.5em; vertical-align: middle; display: none;">🔍 Scanning...</span></h2>
|
||||
<div id="device-list">Loading devices...</div>
|
||||
<div style="margin-top: 20px;">
|
||||
@@ -28,7 +123,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Data Sync -->
|
||||
<!-- Tab 3: Data Sync -->
|
||||
<div id="tab-sync" class="tab-content">
|
||||
<h2>Initial Data Sync</h2>
|
||||
<p>Before migrating, fetch your presets, recents, and configured sources from the device to ensure they are available locally.</p>
|
||||
@@ -46,7 +141,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 3: Migration -->
|
||||
<!-- Tab 4: Migration -->
|
||||
<div id="tab-migration" class="tab-content">
|
||||
<h2>Device Migration</h2>
|
||||
<div class="device-selection">
|
||||
@@ -184,28 +279,91 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 4: Settings -->
|
||||
<div id="tab-settings" class="tab-content">
|
||||
<h2>System Settings</h2>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label for="target-domain">Target Domain:</label>
|
||||
<input type="text" id="target-domain" placeholder="http://localhost:8000" style="width: 300px;">
|
||||
<span style="font-size: 0.8em; color: #666;">(This URL will be used for standard services)</span>
|
||||
<!-- Tab 5: Interactions -->
|
||||
<div id="tab-interactions" class="tab-content">
|
||||
<h2>Recorded Interactions</h2>
|
||||
<p>Analysis of traffic handled by this service (self) and proxied to Bose (upstream).</p>
|
||||
|
||||
<div id="interaction-stats-container" class="summary-box">
|
||||
<div style="display: flex; gap: 20px; align-items: center; margin-bottom: 15px;">
|
||||
<p style="margin: 0;">Total Requests: <strong id="total-requests">0</strong></p>
|
||||
<button onclick="fetchInteractionStats()">Refresh Stats</button>
|
||||
<div style="margin-left: auto; text-align: right;">
|
||||
<button onclick="cleanupSessions()" class="btn-danger">Cleanup old sessions</button>
|
||||
<div style="font-size: 0.75em; color: #666; margin-top: 3px;">Keeps only the 10 most recent sessions</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 20px;">
|
||||
<div style="flex: 1; border-right: 1px solid #eee; padding-right: 20px;">
|
||||
<h3>By Service</h3>
|
||||
<ul id="stats-by-service" class="stats-list"></ul>
|
||||
</div>
|
||||
<div style="flex: 2;">
|
||||
<h3>Sessions</h3>
|
||||
<div id="stats-by-session-container" style="max-height: 200px; overflow-y: auto; border: 1px solid #eee; padding: 5px; border-radius: 4px;">
|
||||
<ul id="stats-by-session" class="stats-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label for="proxy-domain">Proxy Domain:</label>
|
||||
<input type="text" id="proxy-domain" placeholder="http://localhost:8000" style="width: 300px;">
|
||||
<span style="font-size: 0.8em; color: #666;">(This URL will be used to proxy upstream Bose services)</span>
|
||||
|
||||
<div id="browse-recordings" class="summary-box" style="margin-top: 20px;">
|
||||
<h3>Browse Recordings</h3>
|
||||
<div style="margin-bottom: 15px; display: flex; gap: 15px; align-items: center; background: #f9f9f9; padding: 10px; border-radius: 4px;">
|
||||
<div>
|
||||
<label for="filter-session">Session:</label>
|
||||
<select id="filter-session" onchange="fetchInteractions()">
|
||||
<option value="">All Sessions</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filter-category">Category:</label>
|
||||
<select id="filter-category" onchange="fetchInteractions()">
|
||||
<option value="">All Categories</option>
|
||||
<option value="self">Self (Emulated)</option>
|
||||
<option value="upstream">Upstream (Bose)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filter-since">Since (YYYY-MM-DD HH:mm:ss):</label>
|
||||
<input type="text" id="filter-since" placeholder="e.g. 2026-02-15 15:00:00" size="25" onchange="fetchInteractions()">
|
||||
</div>
|
||||
<button onclick="fetchInteractions()">Apply Filters</button>
|
||||
</div>
|
||||
|
||||
<div id="interactions-list-container" style="max-height: 400px; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="text-align: left; border-bottom: 2px solid #eee;">
|
||||
<th style="padding: 8px;">#</th>
|
||||
<th style="padding: 8px;">Time</th>
|
||||
<th style="padding: 8px;">Method</th>
|
||||
<th style="padding: 8px;">Path</th>
|
||||
<th style="padding: 8px;">Status</th>
|
||||
<th style="padding: 8px;">Category</th>
|
||||
<th style="padding: 8px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="interactions-list">
|
||||
<tr><td colspan="7" style="padding: 20px; text-align: center; color: #666;">No interactions found.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
Proxy Logging:
|
||||
<label><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"> Redact Sensitive Headers</label>
|
||||
<label style="margin-left: 15px;"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"> Log Bodies</label>
|
||||
<label style="margin-left: 15px;"><input type="checkbox" id="proxy-record" onchange="updateProxySettings()"> Record Interactions</label>
|
||||
|
||||
<div id="interaction-viewer" class="summary-box" style="margin-top: 20px; display: none; background: #2b2b2b; color: #a9b7c6;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<h3 style="margin: 0; color: #fff;">Recording Viewer: <span id="viewer-filename" style="font-weight: normal; font-size: 0.8em;"></span></h3>
|
||||
<button onclick="document.getElementById('interaction-viewer').style.display='none'" style="background: #444; color: #fff; border: 1px solid #666;">Close</button>
|
||||
</div>
|
||||
<pre id="interaction-content" style="white-space: pre-wrap; font-family: 'Courier New', Courier, monospace; font-size: 0.9em; margin: 0; padding: 10px; overflow-x: auto; max-height: 600px;"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -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_enabled !== undefined) {
|
||||
document.getElementById('discovery-enabled').checked = settings.discovery_enabled;
|
||||
}
|
||||
fetchProxySettings();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings', error);
|
||||
@@ -43,6 +49,38 @@ async function updateProxySettings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSettings() {
|
||||
const settings = {
|
||||
server_url: document.getElementById('target-domain').value,
|
||||
proxy_url: document.getElementById('proxy-domain').value,
|
||||
discovery_interval: document.getElementById('discovery-interval').value,
|
||||
discovery_enabled: document.getElementById('discovery-enabled').checked
|
||||
};
|
||||
const status = document.getElementById('settings-status');
|
||||
status.innerText = 'Saving...';
|
||||
status.style.color = 'blue';
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
if (response.ok) {
|
||||
status.innerText = '✅ Settings saved. Restart service to apply all changes (like certificate SANs).';
|
||||
status.style.color = 'green';
|
||||
setTimeout(() => fetchSettings(), 500); // Give backend a moment to settle
|
||||
} else {
|
||||
const err = await response.text();
|
||||
status.innerText = '❌ Failed: ' + err;
|
||||
status.style.color = 'red';
|
||||
}
|
||||
} catch (error) {
|
||||
status.innerText = '❌ Error: ' + error.message;
|
||||
status.style.color = 'red';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDevices() {
|
||||
try {
|
||||
const response = await fetch('/setup/devices');
|
||||
@@ -54,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;
|
||||
@@ -66,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>
|
||||
`;
|
||||
@@ -130,6 +168,11 @@ function openTab(evt, tabId) {
|
||||
content.className += " active";
|
||||
}
|
||||
|
||||
if (tabId === 'tab-interactions') {
|
||||
fetchInteractionStats();
|
||||
fetchInteractions();
|
||||
}
|
||||
|
||||
if (evt) {
|
||||
evt.currentTarget.className += " active";
|
||||
} else {
|
||||
@@ -178,12 +221,271 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchInteractionStats() {
|
||||
console.log('Fetching interaction stats...');
|
||||
try {
|
||||
const response = await fetch('/setup/interaction-stats');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const stats = await response.json();
|
||||
console.log('Fetched interaction stats:', stats);
|
||||
|
||||
document.getElementById('total-requests').innerText = stats.total_requests || stats.TotalRequests || 0;
|
||||
|
||||
const statsContainer = document.getElementById('interaction-stats-container');
|
||||
if (statsContainer) {
|
||||
statsContainer.style.display = 'block';
|
||||
}
|
||||
|
||||
const serviceList = document.getElementById('stats-by-service');
|
||||
serviceList.innerHTML = '';
|
||||
const byService = stats.by_service || stats.ByService;
|
||||
if (byService) {
|
||||
Object.entries(byService).forEach(([service, count]) => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<strong>${service || "unknown"}:</strong> ${count || 0} requests`;
|
||||
serviceList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
const sessionList = document.getElementById('stats-by-session');
|
||||
const sessionFilter = document.getElementById('filter-session');
|
||||
const currentFilter = sessionFilter.value;
|
||||
|
||||
sessionList.innerHTML = '';
|
||||
sessionFilter.innerHTML = '<option value="">All Sessions</option>';
|
||||
|
||||
const bySession = stats.by_session || stats.BySession;
|
||||
if (bySession) {
|
||||
// Sort by session ID (timestamp) descending
|
||||
const sortedSessions = Object.entries(bySession)
|
||||
.sort((a, b) => {
|
||||
const sessionA = a[0] || "";
|
||||
const sessionB = b[0] || "";
|
||||
return sessionB.localeCompare(sessionA);
|
||||
});
|
||||
|
||||
sortedSessions.forEach(([session, count]) => {
|
||||
// Session format is like 20260215-160705-99213
|
||||
// Try to make it more readable: 2026-02-15 16:07:05 (PID 99213)
|
||||
let sessionDisplay = session || "unknown";
|
||||
if (session && session.includes('-')) {
|
||||
const parts = session.split('-');
|
||||
if (parts.length >= 2) {
|
||||
const date = parts[0]; // 20260215
|
||||
const time = parts[1]; // 160705
|
||||
if (date.length === 8 && time.length === 6) {
|
||||
sessionDisplay = `${date.substring(0, 4)}-${date.substring(4, 6)}-${date.substring(6, 8)} ${time.substring(0, 2)}:${time.substring(2, 4)}:${time.substring(4, 6)}`;
|
||||
if (parts.length >= 3) {
|
||||
sessionDisplay += ` (PID ${parts[2]})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `
|
||||
<span class="session-info"><strong>${sessionDisplay}:</strong> ${count || 0} requests</span>
|
||||
<div style="display: flex; gap: 5px;">
|
||||
<button onclick="filterBySession('${session || ""}')" style="font-size: 0.8em; padding: 2px 5px;">Filter</button>
|
||||
<button onclick="deleteSession('${session || ""}')" class="btn-danger" style="font-size: 0.8em; padding: 2px 5px;">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
sessionList.appendChild(li);
|
||||
|
||||
const opt = document.createElement('option');
|
||||
opt.value = session || "";
|
||||
opt.innerText = sessionDisplay;
|
||||
sessionFilter.appendChild(opt);
|
||||
});
|
||||
|
||||
sessionFilter.value = currentFilter;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch interaction stats', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function filterBySession(sessionId) {
|
||||
document.getElementById('filter-session').value = sessionId;
|
||||
fetchInteractions();
|
||||
const browseContainer = document.getElementById('browse-recordings');
|
||||
if (browseContainer) {
|
||||
browseContainer.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession(sessionId) {
|
||||
if (!sessionId) return;
|
||||
if (!confirm(`Are you sure you want to delete session ${sessionId}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/setup/interactions/sessions/${sessionId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (response.ok) {
|
||||
// If the deleted session was selected in the filter, clear the filter
|
||||
const sessionFilter = document.getElementById('filter-session');
|
||||
if (sessionFilter.value === sessionId) {
|
||||
sessionFilter.value = "";
|
||||
fetchInteractions();
|
||||
}
|
||||
fetchInteractionStats();
|
||||
} else {
|
||||
const err = await response.text();
|
||||
alert('Failed to delete session: ' + err);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error deleting session: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupSessions() {
|
||||
if (!confirm('Are you sure you want to cleanup old sessions? Only the 10 most recent ones will be kept.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup/interactions/sessions?keep=10', {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (response.ok) {
|
||||
// Refresh everything
|
||||
document.getElementById('filter-session').value = "";
|
||||
fetchInteractionStats();
|
||||
fetchInteractions();
|
||||
} else {
|
||||
const err = await response.text();
|
||||
alert('Failed to cleanup sessions: ' + err);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error cleaning up sessions: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchInteractions() {
|
||||
console.log('Fetching interactions...');
|
||||
const session = document.getElementById('filter-session').value;
|
||||
const category = document.getElementById('filter-category').value;
|
||||
const since = document.getElementById('filter-since').value;
|
||||
|
||||
let url = '/setup/interactions';
|
||||
const params = [];
|
||||
if (session) params.push(`session=${encodeURIComponent(session)}`);
|
||||
if (category) params.push(`category=${encodeURIComponent(category)}`);
|
||||
if (since) params.push(`since=${encodeURIComponent(since)}`);
|
||||
if (params.length > 0) url += '?' + params.join('&');
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const interactions = await response.json();
|
||||
console.log('Fetched interactions:', interactions);
|
||||
const list = document.getElementById('interactions-list');
|
||||
if (!list) {
|
||||
console.error('Could not find interactions-list element');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the parent summary box if it was hidden
|
||||
const browseContainer = list.closest('.summary-box');
|
||||
if (browseContainer) {
|
||||
browseContainer.style.display = 'block';
|
||||
}
|
||||
|
||||
list.innerHTML = '';
|
||||
|
||||
if (!interactions || interactions.length === 0) {
|
||||
list.innerHTML = '<tr><td colspan="7" style="padding: 20px; text-align: center; color: #666;">No interactions found for current filters.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Default sort: Session desc, then Counter asc
|
||||
// If a specific session is selected, sort primarily by counter asc
|
||||
interactions.sort((a, b) => {
|
||||
const sessionA = a.session || a.Session || "";
|
||||
const sessionB = b.session || b.Session || "";
|
||||
if (sessionA !== sessionB) {
|
||||
return sessionB.localeCompare(sessionA);
|
||||
}
|
||||
const counterA = a.counter || a.Counter || 0;
|
||||
const counterB = b.counter || b.Counter || 0;
|
||||
return counterA - counterB;
|
||||
});
|
||||
|
||||
interactions.forEach(i => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.style.borderBottom = '1px solid #eee';
|
||||
|
||||
const counter = i.counter || i.Counter || 0;
|
||||
const timestamp = i.timestamp || i.Timestamp || "";
|
||||
const method = i.method || i.Method || "";
|
||||
const path = i.path || i.Path || "";
|
||||
const status = i.status || i.Status || "";
|
||||
const category = i.category || i.Category || "";
|
||||
const session = i.session || i.Session || "";
|
||||
const file = i.file || i.File || "";
|
||||
|
||||
let statusClass = '';
|
||||
if (status >= 200 && status < 300) statusClass = 'status-success';
|
||||
else if (status >= 400) statusClass = 'status-error';
|
||||
|
||||
tr.innerHTML = `
|
||||
<td style="padding: 8px; color: #888;">${counter}</td>
|
||||
<td style="padding: 8px; font-size: 0.8em; white-space: nowrap;">${timestamp}</td>
|
||||
<td style="padding: 8px; font-family: monospace;">${method}</td>
|
||||
<td style="padding: 8px; font-size: 0.9em;">${path}</td>
|
||||
<td style="padding: 8px;"><span class="badge ${statusClass}">${status || '???'}</span></td>
|
||||
<td style="padding: 8px;"><span class="badge category-${category}">${category}</span></td>
|
||||
<td style="padding: 8px;"><button onclick="viewInteraction('${file}')">View</button></td>
|
||||
`;
|
||||
list.appendChild(tr);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch interactions', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function viewInteraction(file) {
|
||||
try {
|
||||
const response = await fetch(`/setup/interaction-content?file=${encodeURIComponent(file)}`);
|
||||
const content = await response.text();
|
||||
|
||||
document.getElementById('viewer-filename').innerText = file;
|
||||
document.getElementById('interaction-content').innerText = content;
|
||||
document.getElementById('interaction-viewer').style.display = 'block';
|
||||
document.getElementById('interaction-viewer').scrollIntoView({ behavior: 'smooth' });
|
||||
} catch (error) {
|
||||
alert('Failed to load interaction content: ' + error);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
fetchSettings();
|
||||
fetchDevices();
|
||||
triggerDiscovery();
|
||||
fetchVersion();
|
||||
|
||||
document.getElementById('sync-now-btn').onclick = startSync;
|
||||
const syncBtn = document.getElementById('sync-now-btn');
|
||||
if (syncBtn) syncBtn.onclick = startSync;
|
||||
});
|
||||
|
||||
|
||||
@@ -213,6 +515,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';
|
||||
@@ -262,6 +585,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);
|
||||
@@ -322,6 +651,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';
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -64,6 +65,7 @@ func TestLoggingProxy_LogRequest(t *testing.T) {
|
||||
defer func() { _ = os.Unsetenv("LOG_PROXY_BODY") }()
|
||||
|
||||
lp := NewLoggingProxy("http://example.com", true)
|
||||
lp.LogBody = true
|
||||
|
||||
body := "test body content"
|
||||
req := httptest.NewRequest("POST", "http://example.com/api", strings.NewReader(body))
|
||||
@@ -77,4 +79,47 @@ func TestLoggingProxy_LogRequest(t *testing.T) {
|
||||
if string(readBody) != body {
|
||||
t.Errorf("Request body was consumed or changed, got %q, want %q", string(readBody), body)
|
||||
}
|
||||
|
||||
// Test truncation
|
||||
lp.MaxBodySize = 4
|
||||
req2 := httptest.NewRequest("POST", "http://example.com/api", strings.NewReader("1234567890"))
|
||||
req2.Header.Set("Content-Type", "text/plain")
|
||||
lp.LogRequest(req2)
|
||||
}
|
||||
|
||||
func TestLoggingProxy_LogResponse(t *testing.T) {
|
||||
lp := NewLoggingProxy("http://example.com", true)
|
||||
lp.LogBody = true
|
||||
|
||||
body := "response content"
|
||||
req := httptest.NewRequest("GET", "http://example.com/api", nil)
|
||||
w := httptest.NewRecorder()
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.WriteString(body)
|
||||
res := w.Result()
|
||||
res.Request = req
|
||||
|
||||
lp.LogResponse(res)
|
||||
|
||||
// Check if body is still readable
|
||||
readBody, _ := io.ReadAll(res.Body)
|
||||
if string(readBody) != body {
|
||||
t.Errorf("Response body was consumed or changed, got %q, want %q", string(readBody), body)
|
||||
}
|
||||
|
||||
// Test with recorder
|
||||
tmpDir, _ := os.MkdirTemp("", "proxy-recorder-test")
|
||||
defer os.RemoveAll(tmpDir)
|
||||
recorder := NewRecorder(tmpDir)
|
||||
lp.SetRecorder(recorder)
|
||||
lp.RecordEnabled = true
|
||||
|
||||
lp.LogResponse(res)
|
||||
|
||||
// Verify recording exists
|
||||
interactionsDir := filepath.Join(tmpDir, "interactions", recorder.SessionID, "upstream", "api")
|
||||
files, _ := os.ReadDir(interactionsDir)
|
||||
if len(files) == 0 {
|
||||
t.Error("LogResponse did not record the interaction")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -26,6 +27,26 @@ type Recorder struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// InteractionStats represents statistics for recorded interactions.
|
||||
type InteractionStats struct {
|
||||
TotalRequests int `json:"total_requests"`
|
||||
ByService map[string]int `json:"by_service"`
|
||||
BySession map[string]int `json:"by_session"`
|
||||
}
|
||||
|
||||
// Interaction represents a single recorded HTTP interaction.
|
||||
type Interaction struct {
|
||||
ID string `json:"id"`
|
||||
Session string `json:"session"`
|
||||
Category string `json:"category"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
File string `json:"file"`
|
||||
Counter int `json:"counter"`
|
||||
Status int `json:"status"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// NewRecorder creates a new HTTP interaction recorder.
|
||||
func NewRecorder(baseDir string) *Recorder {
|
||||
sessionID := time.Now().Format("20060102-150405") + "-" + fmt.Sprintf("%d", os.Getpid())
|
||||
@@ -221,3 +242,246 @@ func (r *Recorder) updateEnvFile(newVars map[string]string) error {
|
||||
|
||||
return os.WriteFile(envFile, data, 0644)
|
||||
}
|
||||
|
||||
// GetInteractionStats returns statistics about recorded interactions.
|
||||
func (r *Recorder) GetInteractionStats() (*InteractionStats, error) {
|
||||
stats := &InteractionStats{
|
||||
ByService: make(map[string]int),
|
||||
BySession: make(map[string]int),
|
||||
}
|
||||
|
||||
interactionsDir := filepath.Join(r.BaseDir, "interactions")
|
||||
if _, err := os.Stat(interactionsDir); os.IsNotExist(err) {
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
err := filepath.Walk(interactionsDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !info.IsDir() && strings.HasSuffix(info.Name(), ".http") {
|
||||
stats.TotalRequests++
|
||||
|
||||
// Extract category (self/upstream) and session from path
|
||||
// Path is like: .../interactions/<session>/<category>/...
|
||||
rel, err := filepath.Rel(interactionsDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parts := strings.Split(rel, string(filepath.Separator))
|
||||
if len(parts) >= 2 {
|
||||
sessionID := parts[0]
|
||||
category := parts[1]
|
||||
stats.BySession[sessionID]++
|
||||
stats.ByService[category]++
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return stats, err
|
||||
}
|
||||
|
||||
// ListInteractions returns a list of recorded interactions.
|
||||
func (r *Recorder) ListInteractions(sessionFilter, categoryFilter, sinceFilter string) ([]Interaction, error) {
|
||||
interactions := make([]Interaction, 0)
|
||||
interactionsDir := filepath.Join(r.BaseDir, "interactions")
|
||||
|
||||
if _, err := os.Stat(interactionsDir); os.IsNotExist(err) {
|
||||
return interactions, nil
|
||||
}
|
||||
|
||||
err := filepath.Walk(interactionsDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() || !strings.HasSuffix(info.Name(), ".http") {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(interactionsDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parts := strings.Split(rel, string(filepath.Separator))
|
||||
if len(parts) < 3 {
|
||||
return nil
|
||||
}
|
||||
|
||||
sessionID, category := parts[0], parts[1]
|
||||
if (sessionFilter != "" && sessionID != sessionFilter) || (categoryFilter != "" && category != categoryFilter) {
|
||||
return nil
|
||||
}
|
||||
|
||||
interaction, ok := r.parseInteractionFile(rel, path, parts)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if sinceFilter != "" && interaction.Timestamp != "" {
|
||||
fullTS := r.getFullTimestamp(sessionID, interaction.ID)
|
||||
|
||||
normalizedSince := strings.ReplaceAll(strings.ReplaceAll(sinceFilter, ":", "-"), " ", "-")
|
||||
if fullTS != "" && fullTS < normalizedSince {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
interactions = append(interactions, interaction)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return interactions, err
|
||||
}
|
||||
|
||||
func (r *Recorder) parseInteractionFile(rel, path string, parts []string) (Interaction, bool) {
|
||||
sessionID, category := parts[0], parts[1]
|
||||
filename := parts[len(parts)-1]
|
||||
fnParts := strings.Split(strings.TrimSuffix(filename, ".http"), "-")
|
||||
|
||||
date := ""
|
||||
if len(sessionID) >= 8 {
|
||||
date = sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8]
|
||||
}
|
||||
|
||||
timestamp := ""
|
||||
|
||||
if len(fnParts) >= 4 {
|
||||
timeStr := fnParts[1] + ":" + fnParts[2] + ":" + fnParts[3]
|
||||
timestamp = timeStr
|
||||
|
||||
if date != "" {
|
||||
timestamp = date + " " + timeStr
|
||||
}
|
||||
}
|
||||
|
||||
requestPath := "/" + strings.Join(parts[2:len(parts)-1], "/")
|
||||
if requestPath == "/root" {
|
||||
requestPath = "/"
|
||||
}
|
||||
|
||||
method, counter := "UNKNOWN", 0
|
||||
if len(fnParts) >= 1 {
|
||||
_, _ = fmt.Sscanf(fnParts[0], "%d", &counter)
|
||||
}
|
||||
|
||||
if len(fnParts) >= 5 {
|
||||
method = fnParts[4]
|
||||
}
|
||||
|
||||
return Interaction{
|
||||
ID: filename,
|
||||
Session: sessionID,
|
||||
Category: category,
|
||||
Method: method,
|
||||
Path: requestPath,
|
||||
File: rel,
|
||||
Counter: counter,
|
||||
Status: r.peekStatus(path),
|
||||
Timestamp: timestamp,
|
||||
}, true
|
||||
}
|
||||
|
||||
func (r *Recorder) getFullTimestamp(sessionID, filename string) string {
|
||||
if len(sessionID) < 8 {
|
||||
return ""
|
||||
}
|
||||
|
||||
date := sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8]
|
||||
fnParts := strings.Split(strings.TrimSuffix(filename, ".http"), "-")
|
||||
|
||||
if len(fnParts) < 4 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return date + "-" + fnParts[1] + "-" + fnParts[2] + "-" + fnParts[3]
|
||||
}
|
||||
|
||||
func (r *Recorder) peekStatus(path string) int {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
lines := strings.Split(string(content), "\n")
|
||||
for _, line := range lines {
|
||||
if !strings.Contains(line, "// Response:") {
|
||||
continue
|
||||
}
|
||||
|
||||
trimmedLine := strings.TrimPrefix(strings.TrimSpace(line), "//")
|
||||
trimmedLine = strings.TrimPrefix(strings.TrimSpace(trimmedLine), "Response:")
|
||||
trimmedLine = strings.TrimSpace(trimmedLine)
|
||||
|
||||
status := 0
|
||||
_, _ = fmt.Sscanf(trimmedLine, "%d", &status)
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// DeleteSession deletes a specific recording session.
|
||||
func (r *Recorder) DeleteSession(sessionID string) error {
|
||||
if sessionID == "" {
|
||||
return fmt.Errorf("session ID is required")
|
||||
}
|
||||
|
||||
sessionDir := filepath.Join(r.BaseDir, "interactions", sessionID)
|
||||
|
||||
return os.RemoveAll(sessionDir)
|
||||
}
|
||||
|
||||
// CleanupSessions deletes all but the most recent keepCount sessions.
|
||||
func (r *Recorder) CleanupSessions(keepCount int) error {
|
||||
interactionsDir := filepath.Join(r.BaseDir, "interactions")
|
||||
|
||||
entries, err := os.ReadDir(interactionsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
var sessions []os.DirEntry
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
sessions = append(sessions, entry)
|
||||
}
|
||||
}
|
||||
|
||||
if len(sessions) <= keepCount {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sort sessions by name (timestamp) descending to keep the newest ones
|
||||
// Session ID format: 20260102-150405-PID
|
||||
sort.Slice(sessions, func(i, j int) bool {
|
||||
return sessions[i].Name() > sessions[j].Name()
|
||||
})
|
||||
|
||||
for i := keepCount; i < len(sessions); i++ {
|
||||
sessionDir := filepath.Join(interactionsDir, sessions[i].Name())
|
||||
if err := os.RemoveAll(sessionDir); err != nil {
|
||||
return fmt.Errorf("failed to delete session %s: %w", sessions[i].Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInteractionContent returns the raw content of a recorded interaction.
|
||||
func (r *Recorder) GetInteractionContent(relPath string) ([]byte, error) {
|
||||
fullPath := filepath.Join(r.BaseDir, "interactions", relPath)
|
||||
return os.ReadFile(fullPath)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -19,6 +22,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 +108,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 +132,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 +145,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 +164,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 +205,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 +267,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 +309,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{
|
||||
@@ -327,3 +342,432 @@ func TestRecorder_EnvFile(t *testing.T) {
|
||||
t.Errorf("Expected ip to be 192.168.178.35, got %s", content["session"]["ip"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecorder_GetInteractionStats(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "recorder-stats-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
r := NewRecorder(tmpDir)
|
||||
r.SessionID = "20260215-120000-12345"
|
||||
|
||||
// Create some dummy interactions
|
||||
files := []string{
|
||||
"interactions/20260215-120000-12345/self/setup/0001-12-00-01.000-GET.http",
|
||||
"interactions/20260215-120000-12345/upstream/marge/0002-12-00-02.000-POST.http",
|
||||
"interactions/20260215-130000-67890/self/setup/0001-13-00-01.000-GET.http",
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
path := filepath.Join(tmpDir, f)
|
||||
os.MkdirAll(filepath.Dir(path), 0755)
|
||||
os.WriteFile(path, []byte("test"), 0644)
|
||||
}
|
||||
|
||||
stats, err := r.GetInteractionStats()
|
||||
if err != nil {
|
||||
t.Fatalf("GetInteractionStats failed: %v", err)
|
||||
}
|
||||
|
||||
if stats.TotalRequests != 3 {
|
||||
t.Errorf("Expected 3 total requests, got %d", stats.TotalRequests)
|
||||
}
|
||||
|
||||
if stats.ByService["self"] != 2 {
|
||||
t.Errorf("Expected 2 self requests, got %d", stats.ByService["self"])
|
||||
}
|
||||
|
||||
if stats.ByService["upstream"] != 1 {
|
||||
t.Errorf("Expected 1 upstream request, got %d", stats.ByService["upstream"])
|
||||
}
|
||||
|
||||
if stats.BySession["20260215-120000-12345"] != 2 {
|
||||
t.Errorf("Expected 2 requests for session 1, got %d", stats.BySession["20260215-120000-12345"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecorder_ListInteractions(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "recorder-list-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
r := NewRecorder(tmpDir)
|
||||
session1 := "20260215-120000-12345"
|
||||
session2 := "20260215-130000-67890"
|
||||
|
||||
// Create some dummy interactions
|
||||
files := []struct {
|
||||
path string
|
||||
content string
|
||||
}{
|
||||
{
|
||||
path: filepath.Join("interactions", session1, "self", "setup", "0001-12-00-01.555-GET.http"),
|
||||
content: "### GET /setup\n\n> {% \n // Response: 200 OK\n%}\n",
|
||||
},
|
||||
{
|
||||
path: filepath.Join("interactions", session1, "upstream", "marge", "0002-12-00-02.000-POST.http"),
|
||||
content: "### POST /marge\n\n> {% \n // Response: 201 Created\n%}\n",
|
||||
},
|
||||
{
|
||||
path: filepath.Join("interactions", session2, "self", "info", "0001-13-00-05.000-GET.http"),
|
||||
content: "### GET /info\n\n> {% \n // Response: 404 Not Found\n%}\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
path := filepath.Join(tmpDir, f.path)
|
||||
os.MkdirAll(filepath.Dir(path), 0755)
|
||||
os.WriteFile(path, []byte(f.content), 0644)
|
||||
}
|
||||
|
||||
t.Run("List_all", func(t *testing.T) {
|
||||
list, err := r.ListInteractions("", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListInteractions failed: %v", err)
|
||||
}
|
||||
if len(list) != 3 {
|
||||
t.Errorf("Expected 3 interactions, got %d", len(list))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Filter_by_session", func(t *testing.T) {
|
||||
list, err := r.ListInteractions(session1, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListInteractions failed: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Errorf("Expected 2 interactions for session1, got %d", len(list))
|
||||
}
|
||||
for _, i := range list {
|
||||
if i.Session != session1 {
|
||||
t.Errorf("Expected session %s, got %s", session1, i.Session)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Filter_by_category", func(t *testing.T) {
|
||||
list, err := r.ListInteractions("", "upstream", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListInteractions failed: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Errorf("Expected 1 upstream interaction, got %d", len(list))
|
||||
}
|
||||
if list[0].Category != "upstream" {
|
||||
t.Errorf("Expected category upstream, got %s", list[0].Category)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Check_enhanced_fields", func(t *testing.T) {
|
||||
list, err := r.ListInteractions(session1, "self", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListInteractions failed: %v", err)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
t.Fatal("Expected at least one interaction")
|
||||
}
|
||||
i := list[0]
|
||||
if i.Counter != 1 {
|
||||
t.Errorf("Expected counter 1, got %d", i.Counter)
|
||||
}
|
||||
if i.Status != 200 {
|
||||
t.Errorf("Expected status 200, got %d", i.Status)
|
||||
}
|
||||
if i.Method != "GET" {
|
||||
t.Errorf("Expected method GET, got %s", i.Method)
|
||||
}
|
||||
if i.Timestamp != "2026-02-15 12:00:01.555" {
|
||||
t.Errorf("Expected timestamp 2026-02-15 12:00:01.555, got %s", i.Timestamp)
|
||||
}
|
||||
if i.Path != "/setup" {
|
||||
t.Errorf("Expected path /setup, got %s", i.Path)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Filter_by_since", func(t *testing.T) {
|
||||
// session1 has 2026-02-15 12:00:01.555 and 12:00:02.000
|
||||
// session2 has 2026-02-15 13:00:05.000
|
||||
list, err := r.ListInteractions("", "", "2026-02-15 12:30:00")
|
||||
if err != nil {
|
||||
t.Fatalf("ListInteractions failed: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Errorf("Expected 1 interaction since 12:30:00, got %d", len(list))
|
||||
}
|
||||
if list[0].Session != session2 {
|
||||
t.Errorf("Expected session2, got %s", list[0].Session)
|
||||
}
|
||||
|
||||
list, err = r.ListInteractions("", "", "2026-02-15 12:00:01.600")
|
||||
if err != nil {
|
||||
t.Fatalf("ListInteractions failed: %v", err)
|
||||
}
|
||||
// Should include 12:00:02.000 and 13:00:05.000
|
||||
if len(list) != 2 {
|
||||
t.Errorf("Expected 2 interactions since 12:00:01.600, got %d", len(list))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecorder_DeleteAndCleanup(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "recorder-delete-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
r := NewRecorder(tmpDir)
|
||||
|
||||
// Create 15 dummy sessions
|
||||
for i := 1; i <= 15; i++ {
|
||||
// Use a format that sorts correctly: YYYYMMDD-HHmmss-PID
|
||||
sessionID := fmt.Sprintf("20260215-12%02d00-12345", i)
|
||||
sessionDir := filepath.Join(tmpDir, "interactions", sessionID)
|
||||
os.MkdirAll(sessionDir, 0755)
|
||||
os.WriteFile(filepath.Join(sessionDir, "test.http"), []byte("test"), 0644)
|
||||
}
|
||||
|
||||
t.Run("Delete_specific_session", func(t *testing.T) {
|
||||
sessionToDelete := "20260215-120500-12345"
|
||||
err := r.DeleteSession(sessionToDelete)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteSession failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "interactions", sessionToDelete)); !os.IsNotExist(err) {
|
||||
t.Errorf("Session %s still exists after deletion", sessionToDelete)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Cleanup_sessions", func(t *testing.T) {
|
||||
err := r.CleanupSessions(10)
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupSessions failed: %v", err)
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(filepath.Join(tmpDir, "interactions"))
|
||||
if len(entries) != 10 {
|
||||
t.Errorf("Expected 10 sessions to remain, got %d", len(entries))
|
||||
}
|
||||
|
||||
// Ensure newest sessions are kept
|
||||
// We created 1 to 15, deleted 5. Remaining: 1-4, 6-15 (14 sessions)
|
||||
// Cleanup(10) should keep 15, 14, 13, 12, 11, 10, 9, 8, 7, 6.
|
||||
expectedRemaining := []string{
|
||||
"20260215-120600-12345",
|
||||
"20260215-120700-12345",
|
||||
"20260215-120800-12345",
|
||||
"20260215-120900-12345",
|
||||
"20260215-121000-12345",
|
||||
"20260215-121100-12345",
|
||||
"20260215-121200-12345",
|
||||
"20260215-121300-12345",
|
||||
"20260215-121400-12345",
|
||||
"20260215-121500-12345",
|
||||
}
|
||||
|
||||
for _, sessionID := range expectedRemaining {
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "interactions", sessionID)); os.IsNotExist(err) {
|
||||
t.Errorf("Expected session %s to remain, but it was deleted", sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// Check one that should be deleted
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "interactions", "20260215-120100-12345")); !os.IsNotExist(err) {
|
||||
t.Errorf("Session 20260215-120100-12345 should have been cleaned up")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecorder_GetInteractionContent(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "recorder-content-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
r := NewRecorder(tmpDir)
|
||||
relPath := filepath.Join(r.SessionID, "self", "test", "0001-GET.http")
|
||||
fullPath := filepath.Join(tmpDir, "interactions", relPath)
|
||||
os.MkdirAll(filepath.Dir(fullPath), 0755)
|
||||
|
||||
expectedContent := "test content"
|
||||
os.WriteFile(fullPath, []byte(expectedContent), 0644)
|
||||
|
||||
content, err := r.GetInteractionContent(relPath)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInteractionContent failed: %v", err)
|
||||
}
|
||||
|
||||
if string(content) != expectedContent {
|
||||
t.Errorf("Expected %s, got %s", expectedContent, string(content))
|
||||
}
|
||||
|
||||
_, err = r.GetInteractionContent("non-existent")
|
||||
if err == nil {
|
||||
t.Error("Expected error for non-existent file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecorder_Record_FullExchange(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "recorder-full-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
r := NewRecorder(tmpDir)
|
||||
|
||||
req := &http.Request{
|
||||
Method: "POST",
|
||||
URL: &url.URL{
|
||||
Path: "/test",
|
||||
},
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("request body")),
|
||||
}
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
|
||||
res := &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("response body")),
|
||||
Request: req,
|
||||
}
|
||||
res.Header.Set("Content-Type", "application/json")
|
||||
|
||||
err = r.Record("self", req, res)
|
||||
if err != nil {
|
||||
t.Fatalf("Record failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify file content
|
||||
interactionsDir := filepath.Join(tmpDir, "interactions", r.SessionID, "self", "test")
|
||||
files, _ := os.ReadDir(interactionsDir)
|
||||
if len(files) == 0 {
|
||||
t.Fatal("No recording file found")
|
||||
}
|
||||
|
||||
content, _ := os.ReadFile(filepath.Join(interactionsDir, files[0].Name()))
|
||||
contentStr := string(content)
|
||||
|
||||
if !strings.Contains(contentStr, "request body") {
|
||||
t.Error("Recording does not contain request body")
|
||||
}
|
||||
if !strings.Contains(contentStr, "Response: 200 OK") {
|
||||
t.Error("Recording does not contain response status")
|
||||
}
|
||||
if !strings.Contains(contentStr, "response body") {
|
||||
t.Error("Recording does not contain response body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecorder_Record_BinaryResponse(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "recorder-binary-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
r := NewRecorder(tmpDir)
|
||||
|
||||
req := &http.Request{
|
||||
Method: "GET",
|
||||
URL: &url.URL{Path: "/image"},
|
||||
}
|
||||
|
||||
res := &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(bytes.NewBuffer([]byte{0x00, 0x01, 0x02, 0x03})),
|
||||
Request: req,
|
||||
}
|
||||
res.Header.Set("Content-Type", "image/png")
|
||||
|
||||
err = r.Record("self", req, res)
|
||||
if err != nil {
|
||||
t.Fatalf("Record failed: %v", err)
|
||||
}
|
||||
|
||||
interactionsDir := filepath.Join(tmpDir, "interactions", r.SessionID, "self", "image")
|
||||
files, _ := os.ReadDir(interactionsDir)
|
||||
content, _ := os.ReadFile(filepath.Join(interactionsDir, files[0].Name()))
|
||||
contentStr := string(content)
|
||||
|
||||
if !strings.Contains(contentStr, "[Binary response body: 4 bytes]") {
|
||||
t.Error("Recording does not correctly report binary response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecorder_ListInteractions_FullTimestamp(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "recorder-full-ts-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
r := NewRecorder(tmpDir)
|
||||
sessionID := "20260215-100000-12345"
|
||||
r.SessionID = sessionID
|
||||
|
||||
// Create some dummy recordings
|
||||
basePath := filepath.Join(tmpDir, "interactions", sessionID, "self", "test")
|
||||
os.MkdirAll(basePath, 0755)
|
||||
|
||||
files := []string{
|
||||
"0001-10-00-01.000-GET.http",
|
||||
"0002-11-00-00.000-GET.http",
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
os.WriteFile(filepath.Join(basePath, f), []byte("test"), 0644)
|
||||
}
|
||||
|
||||
t.Run("Check_Full_Timestamp_Display", func(t *testing.T) {
|
||||
interactions, err := r.ListInteractions(sessionID, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListInteractions failed: %v", err)
|
||||
}
|
||||
|
||||
if len(interactions) != 2 {
|
||||
t.Fatalf("Expected 2 interactions, got %d", len(interactions))
|
||||
}
|
||||
|
||||
expectedTS := "2026-02-15 10:00:01.000"
|
||||
if interactions[0].Timestamp != expectedTS {
|
||||
t.Errorf("Expected timestamp %s, got %s", expectedTS, interactions[0].Timestamp)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Filter_By_Full_Date_Time", func(t *testing.T) {
|
||||
// Filter for interactions since 10:30:00 on that day
|
||||
interactions, err := r.ListInteractions(sessionID, "", "2026-02-15 10:30:00")
|
||||
if err != nil {
|
||||
t.Fatalf("ListInteractions failed: %v", err)
|
||||
}
|
||||
|
||||
if len(interactions) != 1 {
|
||||
t.Fatalf("Expected 1 interaction, got %d", len(interactions))
|
||||
}
|
||||
|
||||
if interactions[0].ID != "0002-11-00-00.000-GET.http" {
|
||||
t.Errorf("Expected 0002-..., got %s", interactions[0].ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Filter_By_Date_Only", func(t *testing.T) {
|
||||
// Filter for interactions since the day before
|
||||
interactions, err := r.ListInteractions(sessionID, "", "2026-02-14")
|
||||
if err != nil {
|
||||
t.Fatalf("ListInteractions failed: %v", err)
|
||||
}
|
||||
|
||||
if len(interactions) != 2 {
|
||||
t.Fatalf("Expected 2 interactions, got %d", len(interactions))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+131
-14
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -58,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"`
|
||||
@@ -91,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"`
|
||||
@@ -259,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
|
||||
@@ -283,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,11 +447,30 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
|
||||
method = MigrationMethodXML
|
||||
}
|
||||
|
||||
if method == MigrationMethodHosts {
|
||||
return m.migrateViaHosts(deviceIP, targetURL)
|
||||
var logs string
|
||||
|
||||
// 0. Off-device backup for safety
|
||||
if backupErr := m.BackupConfigOffDevice(deviceIP); backupErr != nil {
|
||||
logs += fmt.Sprintf("Warning: Failed to create off-device backup: %v\n", backupErr)
|
||||
// We continue, but this is a warning
|
||||
} else {
|
||||
logs += "Successfully created off-device backup of current configuration.\n"
|
||||
}
|
||||
|
||||
var logs string
|
||||
// 0b. Pre-flight check for SSH /rw permissions
|
||||
client := m.NewSSH(deviceIP)
|
||||
|
||||
rwCmd := "(rw || mount -o remount,rw /)"
|
||||
if rwTest, rwErr := client.Run(rwCmd); rwErr != nil {
|
||||
return logs, fmt.Errorf("pre-flight check failed: cannot gain write access (cmd: %s, output: %s): %w", rwCmd, rwTest, rwErr)
|
||||
}
|
||||
|
||||
logs += "Pre-flight: Write access verified.\n"
|
||||
|
||||
if method == MigrationMethodHosts {
|
||||
out, err := m.migrateViaHosts(deviceIP, targetURL)
|
||||
return logs + out, err
|
||||
}
|
||||
|
||||
out, err := m.EnsureRemoteServices(deviceIP)
|
||||
logs += "Ensuring remote services:\n" + out + "\n"
|
||||
@@ -459,7 +492,6 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
|
||||
}
|
||||
|
||||
// If we have a proxyURL and can read current config, use it
|
||||
client := m.NewSSH(deviceIP)
|
||||
if curCfg, curCfgErr := client.Run(fmt.Sprintf("cat %s", SoundTouchSdkPrivateCfgPath)); curCfgErr == nil && curCfg != "" {
|
||||
logs += "Read current configuration\n"
|
||||
|
||||
@@ -490,7 +522,6 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
|
||||
|
||||
// 0. Backup original config if it doesn't exist
|
||||
remotePath := SoundTouchSdkPrivateCfgPath
|
||||
rwCmd := "(rw || mount -o remount,rw /)"
|
||||
|
||||
if backupOut, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err != nil {
|
||||
logs += fmt.Sprintf("Backing up original config to %s.original (check: %s)\n", remotePath, backupOut)
|
||||
@@ -531,6 +562,70 @@ func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options m
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
// BackupConfigOffDevice creates a local backup of the speaker's configuration files in the DataStore.
|
||||
func (m *Manager) BackupConfigOffDevice(deviceIP string) error {
|
||||
if m.DataStore == nil {
|
||||
return fmt.Errorf("datastore not configured")
|
||||
}
|
||||
|
||||
client := m.NewSSH(deviceIP)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
accountID := info.MargeAccountUUID
|
||||
deviceID := info.SerialNumber
|
||||
|
||||
if deviceID == "" {
|
||||
deviceID = info.DeviceID
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 1. Backup SoundTouchSdkPrivateCfg.xml
|
||||
if config, err := client.Run(fmt.Sprintf("cat %s", SoundTouchSdkPrivateCfgPath)); err == nil && config != "" {
|
||||
backupPath := filepath.Join(deviceDir, "SoundTouchSdkPrivateCfg.xml.bak")
|
||||
if err := os.WriteFile(backupPath, []byte(config), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write config backup: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Backup /etc/hosts
|
||||
if hosts, err := client.Run("cat /etc/hosts"); err == nil && hosts != "" {
|
||||
backupPath := filepath.Join(deviceDir, "hosts.bak")
|
||||
if err := os.WriteFile(backupPath, []byte(hosts), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write hosts backup: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BackupConfig creates a backup of the current configuration on the speaker.
|
||||
func (m *Manager) BackupConfig(deviceIP string) (string, error) {
|
||||
client := m.NewSSH(deviceIP)
|
||||
@@ -1146,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)
|
||||
|
||||
@@ -1162,6 +1276,9 @@ func (m *Manager) SyncDeviceData(deviceIP string) error {
|
||||
// 4. Fetch Sources
|
||||
m.syncSources(deviceIP, accountID, deviceID)
|
||||
|
||||
// 5. Create off-device backup of system configuration
|
||||
_ = m.BackupConfigOffDevice(deviceIP)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
type mockSSH struct {
|
||||
@@ -762,6 +763,98 @@ func TestReboot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupConfigOffDevice(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "backup-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
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><margeAccountUUID>%s</margeAccountUUID><components><component><componentCategory>SCM</componentCategory><serialNumber>%s</serialNumber></component></components></info>`, serial, accountID, serial)
|
||||
}))
|
||||
defer infoServer.Close()
|
||||
|
||||
// Extract IP and port
|
||||
deviceIP := infoServer.Listener.Addr().String()
|
||||
|
||||
// Mock SSH to return some config and hosts content
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
if strings.Contains(command, SoundTouchSdkPrivateCfgPath) {
|
||||
return "<SoundTouchSdkPrivateCfg><margeServerUrl>http://original</margeServerUrl></SoundTouchSdkPrivateCfg>", nil
|
||||
}
|
||||
if strings.Contains(command, "/etc/hosts") {
|
||||
return "127.0.0.1 localhost\n192.168.1.1 bmx.bose.com", nil
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
err = m.BackupConfigOffDevice(deviceIP)
|
||||
if err != nil {
|
||||
t.Fatalf("BackupConfigOffDevice failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify files were created in datastore
|
||||
deviceDir := m.DataStore.AccountDeviceDir(accountID, serial)
|
||||
configPath := filepath.Join(deviceDir, "SoundTouchSdkPrivateCfg.xml.bak")
|
||||
hostsPath := filepath.Join(deviceDir, "hosts.bak")
|
||||
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
t.Errorf("Expected config backup at %s, but it doesn't exist", configPath)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(hostsPath); os.IsNotExist(err) {
|
||||
t.Errorf("Expected hosts backup at %s, but it doesn't exist", hostsPath)
|
||||
}
|
||||
|
||||
// Verify content
|
||||
configContent, _ := os.ReadFile(configPath)
|
||||
if !strings.Contains(string(configContent), "http://original") {
|
||||
t.Errorf("Unexpected config backup content: %s", string(configContent))
|
||||
}
|
||||
|
||||
hostsContent, _ := os.ReadFile(hostsPath)
|
||||
if !strings.Contains(string(hostsContent), "bmx.bose.com") {
|
||||
t.Errorf("Unexpected hosts backup content: %s", string(hostsContent))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSpeaker_PreFlightFailure(t *testing.T) {
|
||||
m := NewManager("http://localhost:8000", nil, nil)
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
if strings.Contains(command, "mount -o remount,rw /") {
|
||||
return "mount: / is read-only", fmt.Errorf("remount failed")
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
_, err := m.MigrateSpeaker("192.168.1.10", "", "", nil, MigrationMethodXML)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error during pre-flight write check, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "pre-flight check failed") {
|
||||
t.Errorf("Expected pre-flight error message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return strings.Contains(s, substr)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
Here is a `README.md` you can place next to your install script (or in your repo) to document installation, configuration, updates, and debugging.
|
||||
|
||||
---
|
||||
|
||||
# SoundTouch Service (systemd install)
|
||||
|
||||
This setup installs `soundtouch-service` from the official GitHub release and runs it as a hardened systemd service.
|
||||
|
||||
It supports:
|
||||
|
||||
* Automatic start on boot
|
||||
* Binding to privileged ports (80 / 443) without running as root
|
||||
* Config via environment file
|
||||
* Clean updates
|
||||
* Safe re-runs of the installer
|
||||
|
||||
---
|
||||
|
||||
# Installation
|
||||
|
||||
Run the installer script:
|
||||
|
||||
```bash
|
||||
sudo bash install-soundtouch-service.sh
|
||||
```
|
||||
|
||||
You can override defaults:
|
||||
|
||||
```bash
|
||||
sudo \
|
||||
VERSION=v0.17.0 \
|
||||
HOSTNAME_FQDN=soundtouch.local \
|
||||
HTTP_PORT=80 \
|
||||
HTTPS_PORT=443 \
|
||||
bash install-soundtouch-service.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Configuration
|
||||
|
||||
Configuration lives in:
|
||||
|
||||
```
|
||||
/etc/soundtouch-service/soundtouch-service.env
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
SERVER_URL=http://soundtouch.local
|
||||
HTTPS_SERVER_URL=https://soundtouch.local
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Important: Applying Configuration Changes
|
||||
|
||||
If you change the environment file, you must reload and restart the service.
|
||||
|
||||
Full roundtrip:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart soundtouch-service
|
||||
```
|
||||
|
||||
Usually `daemon-reload` is only needed if the **unit file** changed.
|
||||
|
||||
If only the `.env` file changed:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart soundtouch-service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Service Management
|
||||
|
||||
Check status:
|
||||
|
||||
```bash
|
||||
systemctl status soundtouch-service
|
||||
```
|
||||
|
||||
Enable at boot:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable soundtouch-service
|
||||
```
|
||||
|
||||
Disable:
|
||||
|
||||
```bash
|
||||
sudo systemctl disable soundtouch-service
|
||||
```
|
||||
|
||||
Stop / start manually:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop soundtouch-service
|
||||
sudo systemctl start soundtouch-service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Logs & Debugging
|
||||
|
||||
View recent logs:
|
||||
|
||||
```bash
|
||||
journalctl -u soundtouch-service -e --no-pager
|
||||
```
|
||||
|
||||
Follow logs live:
|
||||
|
||||
```bash
|
||||
journalctl -u soundtouch-service -f
|
||||
```
|
||||
|
||||
Show logs from current boot:
|
||||
|
||||
```bash
|
||||
journalctl -u soundtouch-service -b
|
||||
```
|
||||
|
||||
If the service fails to start:
|
||||
|
||||
```bash
|
||||
systemctl status soundtouch-service --no-pager
|
||||
```
|
||||
|
||||
Look for:
|
||||
|
||||
* `bind: permission denied` → capability issue
|
||||
* `address already in use` → port conflict
|
||||
* permission errors in DATA_DIR → ownership issue
|
||||
|
||||
---
|
||||
|
||||
# Port Conflicts
|
||||
|
||||
Check if 80/443 are in use:
|
||||
|
||||
```bash
|
||||
sudo ss -tulpn | grep -E ':80|:443'
|
||||
```
|
||||
|
||||
If another service is using the port, either:
|
||||
|
||||
* stop/disable that service
|
||||
* or change `PORT` / `HTTPS_PORT` in the env file
|
||||
|
||||
Then restart the service.
|
||||
|
||||
---
|
||||
|
||||
# Updating to a New Version
|
||||
|
||||
To upgrade, simply run the installer with the desired version as an argument:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh vX.Y.Z
|
||||
```
|
||||
|
||||
The script will:
|
||||
|
||||
* 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; your existing `.env` file and data will be preserved.
|
||||
|
||||
---
|
||||
|
||||
# Reinstall / Reset
|
||||
|
||||
To fully reset:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop soundtouch-service
|
||||
sudo rm -rf /var/lib/soundtouch-service/*
|
||||
sudo systemctl start soundtouch-service
|
||||
```
|
||||
|
||||
To completely remove:
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now soundtouch-service
|
||||
sudo rm /etc/systemd/system/soundtouch-service.service
|
||||
sudo rm -rf /etc/soundtouch-service
|
||||
sudo rm -rf /var/lib/soundtouch-service
|
||||
sudo rm /usr/local/bin/soundtouch-service
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Architecture Auto-Detection
|
||||
|
||||
The installer auto-detects:
|
||||
|
||||
* `linux-armv7`
|
||||
* `linux-arm64`
|
||||
* `linux-amd64`
|
||||
|
||||
Override manually if needed:
|
||||
|
||||
```bash
|
||||
sudo ARCH_ASSET=linux-arm64 bash install-soundtouch-service.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Security Notes
|
||||
|
||||
The service:
|
||||
|
||||
* Runs as a dedicated `soundtouch` system user
|
||||
* Uses `AmbientCapabilities=CAP_NET_BIND_SERVICE`
|
||||
* Does not require `setcap`
|
||||
* Does not run as root
|
||||
* Uses systemd sandboxing (`ProtectSystem`, `PrivateTmp`, etc.)
|
||||
|
||||
---
|
||||
|
||||
# Quick Troubleshooting Checklist
|
||||
|
||||
If something does not work:
|
||||
|
||||
1. Check status:
|
||||
|
||||
```
|
||||
systemctl status soundtouch-service
|
||||
```
|
||||
|
||||
2. Check logs:
|
||||
|
||||
```
|
||||
journalctl -u soundtouch-service -e
|
||||
```
|
||||
|
||||
3. Confirm ports:
|
||||
|
||||
```
|
||||
ss -tulpn | grep -E ':80|:443'
|
||||
```
|
||||
|
||||
4. Confirm env file:
|
||||
|
||||
```
|
||||
cat /etc/soundtouch-service/soundtouch-service.env
|
||||
```
|
||||
|
||||
5. Restart cleanly:
|
||||
|
||||
```
|
||||
sudo systemctl restart soundtouch-service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
If you’d like, I can also provide:
|
||||
|
||||
* A `make update` style wrapper
|
||||
* A rollback mechanism
|
||||
* Or a self-update script with checksum verification
|
||||
@@ -0,0 +1,347 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ==============================================================================
|
||||
# Bose-SoundTouch soundtouch-service installer (systemd, headless)
|
||||
#
|
||||
# Usage:
|
||||
# sudo bash install.sh [vX.Y.Z]
|
||||
#
|
||||
# Examples (override defaults via env vars):
|
||||
#
|
||||
# sudo \
|
||||
# VERSION=v0.17.0 \
|
||||
# HOSTNAME_FQDN=soundtouch.local \
|
||||
# HTTP_PORT=80 \
|
||||
# HTTPS_PORT=443 \
|
||||
# DATA_DIR=/var/lib/soundtouch-service \
|
||||
# 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).
|
||||
# - It installs a systemd unit that can bind privileged ports (80/443) using:
|
||||
# AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
# so you do NOT need setcap and do NOT need to run as root.
|
||||
# - Safe to re-run; it will update binary/config/unit and restart the service.
|
||||
# ==============================================================================
|
||||
|
||||
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}"
|
||||
|
||||
CONFIG_DIR="${CONFIG_DIR:-/etc/soundtouch-service}"
|
||||
ENV_FILE="${ENV_FILE:-$CONFIG_DIR/soundtouch-service.env}"
|
||||
DATA_DIR="${DATA_DIR:-/var/lib/soundtouch-service}"
|
||||
|
||||
SERVICE_USER="${SERVICE_USER:-soundtouch}"
|
||||
SERVICE_GROUP="${SERVICE_GROUP:-soundtouch}"
|
||||
|
||||
# Ports
|
||||
HTTP_PORT="${HTTP_PORT:-80}"
|
||||
HTTPS_PORT="${HTTPS_PORT:-443}"
|
||||
|
||||
# URLs (default uses current hostname + .local)
|
||||
HOSTNAME_FQDN="${HOSTNAME_FQDN:-$(hostname).local}"
|
||||
SERVER_URL="${SERVER_URL:-http://${HOSTNAME_FQDN}}"
|
||||
HTTPS_SERVER_URL="${HTTPS_SERVER_URL:-https://${HOSTNAME_FQDN}}"
|
||||
|
||||
# Additional env vars (mirrors the project's docker-compose.yml)
|
||||
LOG_PROXY_BODY="${LOG_PROXY_BODY:-false}"
|
||||
REDACT_PROXY_LOGS="${REDACT_PROXY_LOGS:-true}"
|
||||
RECORD_INTERACTIONS="${RECORD_INTERACTIONS:-true}"
|
||||
DISCOVERY_INTERVAL="${DISCOVERY_INTERVAL:-5m}"
|
||||
|
||||
# Override if you want to force a specific asset suffix:
|
||||
# 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; }
|
||||
|
||||
need_root() {
|
||||
[[ "${EUID}" -eq 0 ]] || die "Please run as root (e.g. sudo bash $0)."
|
||||
}
|
||||
|
||||
ensure_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
|
||||
}
|
||||
|
||||
apt_install_if_missing() {
|
||||
log "Installing dependencies: $*"
|
||||
apt-get update -y
|
||||
apt-get install -y --no-install-recommends "$@"
|
||||
}
|
||||
|
||||
detect_arch_asset() {
|
||||
# Upstream release naming expects: linux-armv7, linux-arm64, linux-amd64
|
||||
# Map uname -m to those.
|
||||
local m
|
||||
m="$(uname -m)"
|
||||
|
||||
case "$m" in
|
||||
armv7l|armv6l)
|
||||
echo "linux-armv7"
|
||||
;;
|
||||
aarch64)
|
||||
echo "linux-arm64"
|
||||
;;
|
||||
x86_64|amd64)
|
||||
echo "linux-amd64"
|
||||
;;
|
||||
*)
|
||||
die "Unsupported architecture from uname -m: $m (set ARCH_ASSET manually)"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
download_url_for() {
|
||||
local asset="$1"
|
||||
# Release asset pattern used by you earlier:
|
||||
# soundtouch-service-v0.17.0-linux-armv7
|
||||
echo "https://github.com/gesellix/Bose-SoundTouch/releases/download/${VERSION}/soundtouch-service-${VERSION}-${asset}"
|
||||
}
|
||||
|
||||
ensure_user_group() {
|
||||
log "Ensuring service user/group exist: ${SERVICE_USER}:${SERVICE_GROUP}"
|
||||
if ! getent group "${SERVICE_GROUP}" >/dev/null; then
|
||||
groupadd --system "${SERVICE_GROUP}"
|
||||
fi
|
||||
if ! id -u "${SERVICE_USER}" >/dev/null 2>&1; then
|
||||
useradd --system \
|
||||
--home "${DATA_DIR}" \
|
||||
--create-home \
|
||||
--shell /usr/sbin/nologin \
|
||||
--gid "${SERVICE_GROUP}" \
|
||||
"${SERVICE_USER}"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_dirs() {
|
||||
log "Creating directories"
|
||||
mkdir -p "${CONFIG_DIR}" "${DATA_DIR}"
|
||||
|
||||
# Optimized ownership check: only chown if not already owned by service user
|
||||
if [[ "$(stat -c '%U:%G' "${DATA_DIR}")" != "${SERVICE_USER}:${SERVICE_GROUP}" ]]; then
|
||||
log "Adjusting ownership of ${DATA_DIR} to ${SERVICE_USER}:${SERVICE_GROUP}"
|
||||
chown -R "${SERVICE_USER}:${SERVICE_GROUP}" "${DATA_DIR}"
|
||||
fi
|
||||
|
||||
chmod 0755 "${CONFIG_DIR}" "${DATA_DIR}"
|
||||
}
|
||||
|
||||
download_binary() {
|
||||
local asset url tmp
|
||||
asset="${ARCH_ASSET:-$(detect_arch_asset)}"
|
||||
url="$(download_url_for "$asset")"
|
||||
|
||||
log "Downloading binary for ${asset}: ${url}"
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "${tmp}"' EXIT
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL -o "${tmp}/soundtouch-service" "${url}"
|
||||
else
|
||||
wget -qO "${tmp}/soundtouch-service" "${url}"
|
||||
fi
|
||||
|
||||
chmod +x "${tmp}/soundtouch-service"
|
||||
|
||||
# Backup existing binary if it exists
|
||||
if [[ -f "${BIN_PATH}" ]]; then
|
||||
log "Backing up existing binary to ${BIN_PATH}.old"
|
||||
cp -p "${BIN_PATH}" "${BIN_PATH}.old"
|
||||
fi
|
||||
|
||||
install -m 0755 "${tmp}/soundtouch-service" "${BIN_PATH}"
|
||||
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
|
||||
PORT=${HTTP_PORT}
|
||||
HTTPS_PORT=${HTTPS_PORT}
|
||||
DATA_DIR=${DATA_DIR}
|
||||
|
||||
LOG_PROXY_BODY=${LOG_PROXY_BODY}
|
||||
REDACT_PROXY_LOGS=${REDACT_PROXY_LOGS}
|
||||
RECORD_INTERACTIONS=${RECORD_INTERACTIONS}
|
||||
DISCOVERY_INTERVAL=${DISCOVERY_INTERVAL}
|
||||
|
||||
SERVER_URL=${SERVER_URL}
|
||||
HTTPS_SERVER_URL=${HTTPS_SERVER_URL}
|
||||
EOF
|
||||
chmod 0640 "${ENV_FILE}"
|
||||
# group-readable so you can add yourself to the group if desired
|
||||
chown root:"${SERVICE_GROUP}" "${ENV_FILE}" || true
|
||||
}
|
||||
|
||||
write_systemd_unit() {
|
||||
log "Writing systemd unit: /etc/systemd/system/${SERVICE_NAME}.service"
|
||||
cat > "/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
|
||||
[Unit]
|
||||
Description=Bose SoundTouch Service
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${SERVICE_USER}
|
||||
Group=${SERVICE_GROUP}
|
||||
EnvironmentFile=${ENV_FILE}
|
||||
WorkingDirectory=${DATA_DIR}
|
||||
ExecStart=${BIN_PATH}
|
||||
|
||||
# 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=2
|
||||
|
||||
# Sensible hardening (compatible with privileged-port binding)
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
ReadWritePaths=${DATA_DIR}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
}
|
||||
|
||||
reload_enable_start() {
|
||||
log "Reloading systemd, enabling and starting service"
|
||||
systemctl daemon-reload
|
||||
systemctl enable "${SERVICE_NAME}.service"
|
||||
systemctl restart "${SERVICE_NAME}.service"
|
||||
|
||||
log "Verifying service health..."
|
||||
local health_url="http://localhost:${HTTP_PORT}/health"
|
||||
local max_retries=5
|
||||
local count=0
|
||||
local success=false
|
||||
|
||||
while [[ $count -lt $max_retries ]]; do
|
||||
if curl -fs "$health_url" >/dev/null 2>&1; then
|
||||
success=true
|
||||
break
|
||||
fi
|
||||
echo "Waiting for service to respond at $health_url... ($((count+1))/$max_retries)"
|
||||
sleep 2
|
||||
count=$((count+1))
|
||||
done
|
||||
|
||||
if [[ "$success" = true ]]; then
|
||||
log "✅ Service is healthy and responding!"
|
||||
else
|
||||
log "⚠️ Service started but did not respond to health check at $health_url within timeout."
|
||||
log "Check logs with: journalctl -u ${SERVICE_NAME}.service -n 50"
|
||||
fi
|
||||
}
|
||||
|
||||
show_status() {
|
||||
log "Service status"
|
||||
systemctl --no-pager --full status "${SERVICE_NAME}.service" || true
|
||||
|
||||
log "Listening sockets (${HTTP_PORT}/${HTTPS_PORT})"
|
||||
ss -tulpn | grep -E ":((${HTTP_PORT})|(${HTTPS_PORT}))\b" || true
|
||||
|
||||
if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "Status: active"; then
|
||||
log "Firewall check (UFW is active)"
|
||||
if ! ufw status | grep -qE "${HTTP_PORT}.*ALLOW|${HTTPS_PORT}.*ALLOW"; then
|
||||
log "⚠️ UFW is active but ports ${HTTP_PORT}/${HTTPS_PORT} might be blocked."
|
||||
log "Run: sudo ufw allow ${HTTP_PORT}/tcp && sudo ufw allow ${HTTPS_PORT}/tcp"
|
||||
else
|
||||
log "✅ UFW rules for service ports appear to be in place."
|
||||
fi
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Try from another machine:
|
||||
${SERVER_URL}
|
||||
${HTTPS_SERVER_URL}
|
||||
|
||||
If mDNS doesn't work, use the Pi's IP:
|
||||
http://<pi-ip>/
|
||||
https://<pi-ip>/
|
||||
|
||||
Logs:
|
||||
journalctl -u ${SERVICE_NAME}.service -e --no-pager
|
||||
EOF
|
||||
}
|
||||
|
||||
main() {
|
||||
need_root
|
||||
ensure_cmd systemctl
|
||||
ensure_cmd ss
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
|
||||
apt_install_if_missing curl
|
||||
fi
|
||||
|
||||
self_update "$@"
|
||||
|
||||
ensure_user_group
|
||||
ensure_dirs
|
||||
download_binary
|
||||
write_env_file
|
||||
write_systemd_unit
|
||||
reload_enable_start
|
||||
show_status
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user