Compare commits

...
18 Commits
Author SHA1 Message Date
Tobias Gesellchen ca6ca3150a fix: update broken awesome-go repository link
- Replace non-existent https://github.com/shivammg/go-awesome
- With correct https://github.com/avelino/awesome-go repository
- Fixes broken link in post-release checklist documentation
2026-01-10 12:28:41 +01:00
Tobias Gesellchen 04d13c65d3 fix: repair broken anchor links in DEPLOYMENT.md
- Simplify section headers to standard markdown format
- Remove emojis and special characters that break anchor generation
- Update table of contents links to match simplified headers
- Fix ampersand and special character encoding issues in anchors
- All internal links now work with GitHub's automatic anchor generation

Completes documentation link fixes for CI
2026-01-10 12:25:54 +01:00
Tobias Gesellchen ce4ec02468 fix: repair broken anchor links in API-COOKBOOK.md
- Simplify section headers to use standard markdown format
- Remove complex emojis and special characters that break anchor generation
- Update table of contents links to match simplified headers
- Fix 'Volume & Audio' section title with ampersand that caused encoding issues
- All internal links now work properly with GitHub's automatic anchor generation

Resolves failing CI documentation check
2026-01-10 12:24:51 +01:00
Tobias Gesellchen fc9decedd7 fix: make examples non-testable to prevent network operations during tests
- Change '// Output:' to '// Example output:' in all examples
- Examples will still appear in pkg.go.dev documentation
- Prevents examples from running as tests and trying to connect to real devices
- Examples are for documentation purposes, not runtime testing
2026-01-10 12:21:19 +01:00
Tobias Gesellchen 29cbcf48b9 fix: correct API method names and field references in examples
- Fix GetInfo() to GetDeviceInfo() in client examples
- Update discovery examples to use proper constructor patterns
- Fix Volume.Muted to Volume.MuteEnabled field reference
- Correct DiscoveredDevice field names (remove non-existent MACAddress)
- Fix ZoneMember to use IP field instead of IPAddress
- Update Presets examples to use Preset slice and proper methods
- Replace non-existent SubscribeToEvents with NewWebSocketClient pattern
- Fix Capabilities to use Capability field instead of Sources
- Remove duplicate example function names
- Ensure all examples compile and use correct API surface
2026-01-10 12:17:39 +01:00
Tobias Gesellchen 2a9f219d40 docs: enhance pkg.go.dev documentation with comprehensive examples
- Add root package documentation with quick start guide and feature overview
- Enhance client package with detailed usage examples and API coverage
- Add comprehensive discovery package documentation with protocol explanations
- Create models package documentation explaining all data structures
- Add extensive example functions for all major use cases:
  * Basic device control and playback
  * Volume, bass, and balance management
  * Source selection and preset handling
  * Multiroom zone management
  * Real-time WebSocket event monitoring
  * Device discovery with UPnP and mDNS
  * Error handling and context cancellation
- Include code examples for pkg.go.dev's example rendering
- Document API endpoints, data structures, and best practices
- Add hardware compatibility and implementation notes
2026-01-10 12:03:29 +01:00
Tobias Gesellchen 546634572a feat: implement build-time version injection
- Replace hardcoded version with build-time injected variables
- Add version, commit, and date variables to main.go with default values
- Update Makefile ldflags to use consistent variable names
- Add detailed 'version' subcommand showing build info, Go version, and platform
- Maintain compatibility with existing release workflow
- Support both --version flag (simple) and version subcommand (detailed)
2026-01-10 11:58:11 +01:00
Tobias Gesellchen 56566a2b27 apply/fix golangci-lint findings 2026-01-10 11:43:48 +01:00
Tobias Gesellchen dee34c7b56 chore 2026-01-10 11:32:47 +01:00
Tobias Gesellchen d6e998938a golangci-lint run --fix 2026-01-10 11:32:33 +01:00
Tobias Gesellchen b80f8e958b debug: add tag validation debugging to identify release workflow issue 2026-01-10 01:32:12 +01:00
Tobias Gesellchen 7117ff6592 Fix GitHub Actions permissions for release workflow
- Add contents:write permission to allow updating releases
- Add actions:read permission for artifact downloads
- Resolves 'Resource not accessible by integration' error
2026-01-10 01:19:40 +01:00
Tobias Gesellchen d7b1c94b9a Fix directory flattening in release workflow
- Use dedicated collection directory to avoid naming conflicts
- Simplify file movement logic by using release-files directory
- Update artifact upload paths to match new structure
- Resolves mv errors when files have same names as directories
2026-01-10 01:15:45 +01:00
Tobias Gesellchen 3cc45ebd20 debug: add comprehensive directory listings to diagnose build conflicts
Add ls -la output for:
- Working directory before/after build
- Go build cache location and contents
- Go module cache contents
- Post-build state verification

Simple directory listings often reveal file permission issues,
cached artifacts, or leftover files that cause 'File exists' errors
better than complex debugging output.
2026-01-10 01:08:13 +01:00
Tobias Gesellchen a30251854c fix: add debugging and improve build robustness for file conflicts
- Clean build environment before building (remove existing files, clean cache)
- Add atomic checksum generation using temp directory
- Improve error handling with explicit build failure detection
- Add debugging output to diagnose 'File exists' errors
- Use basename in temp operations to avoid path issues

This should resolve the 'Cannot open: File exists' errors occurring
during the darwin/arm64 build process.
2026-01-10 01:07:41 +01:00
Tobias Gesellchen 1a1d37b885 fix: resolve directory overwrite error in checksums generation
Use find -mindepth 2 to only move files from subdirectories, avoiding
the 'mv: cannot overwrite directory' error when flattening the artifact
directory structure. This ensures only the actual binary and checksum
files are moved, not the directories themselves.

Fixes the sha256sum failure in the Generate Checksums workflow step.
2026-01-10 01:04:49 +01:00
Tobias Gesellchen 666839dd4f feat: provide both combined and individual checksum files in releases
- Generate individual SHA256/SHA512 checksums for each binary in matrix jobs
- Maintain combined checksums.sha256/checksums.sha512 files for all binaries
- Upload both types to release assets for maximum user flexibility
- Update release notes with examples for both verification methods
- Filter binary lists to exclude checksum files from combined checksums

Users can now choose between:
- Combined checksums (checksums.sha256) with --ignore-missing flag
- Individual checksums (per-binary .sha256 files) for simpler verification

This provides the best of both approaches for different user preferences.
2026-01-10 01:00:58 +01:00
Tobias Gesellchen 468fdf8836 fix: resolve artifact naming conflicts in release build
- Each build job uploads artifact with unique name (binary filename)
- Checksums job downloads all artifacts and flattens structure
- Use 'find . -type f' to only move files, avoiding directory conflicts
- Add debugging output to troubleshoot artifact structure

Fixes 'artifact with this name already exists' error that was
preventing multiple build jobs from uploading simultaneously.
2026-01-10 00:51:44 +01:00
21 changed files with 1720 additions and 466 deletions
+121 -19
View File
@@ -10,6 +10,10 @@ on:
required: true
default: "v1.0.0"
permissions:
contents: write
actions: read
env:
GO_VERSION_FILE: "go.mod"
@@ -132,11 +136,38 @@ jobs:
echo "Building: $OUTPUT_NAME"
# Debug: Show current state
echo "Working directory: $(pwd)"
echo "Go version: $(go version)"
echo "Files before build:"
ls -la
# Debug: Show Go cache and module cache
echo "Go build cache location: $(go env GOCACHE)"
echo "Go module cache location: $(go env GOMODCACHE)"
echo "Go build cache contents:"
ls -la "$(go env GOCACHE)" 2>/dev/null || echo "Cache directory not accessible"
echo "Go module cache contents (top level):"
ls -la "$(go env GOMODCACHE)" 2>/dev/null || echo "Module cache directory not accessible"
# Ensure clean build environment
rm -f "$OUTPUT_NAME" "$OUTPUT_NAME.sha256" "$OUTPUT_NAME.sha512"
go clean -cache
# Build with optimizations and version info
go build \
if ! go build \
-ldflags="-s -w -X main.version=v${{ needs.validate.outputs.version }} -X main.commit=${{ github.sha }} -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-o "$OUTPUT_NAME" \
./cmd/soundtouch-cli
./cmd/soundtouch-cli; then
echo "❌ Build failed"
echo "Files after failed build:"
ls -la
exit 1
fi
# Debug: Show post-build state
echo "Files after successful build:"
ls -la
# Verify binary was created and is executable
ls -la "$OUTPUT_NAME"
@@ -145,11 +176,37 @@ jobs:
echo "binary_name=$OUTPUT_NAME" >> $GITHUB_OUTPUT
id: build
- name: Generate individual checksum
run: |
OUTPUT_NAME="${{ steps.build.outputs.binary_name }}"
# Use atomic operations to avoid conflicts
TEMP_DIR=$(mktemp -d)
echo "Building checksums for: $OUTPUT_NAME"
echo "Matrix: ${{ matrix.goos }}-${{ matrix.goarch }}"
# Generate checksums in temp directory first
sha256sum "$OUTPUT_NAME" > "${TEMP_DIR}/$(basename "$OUTPUT_NAME").sha256"
sha512sum "$OUTPUT_NAME" > "${TEMP_DIR}/$(basename "$OUTPUT_NAME").sha512"
# Move to final location atomically
mv "${TEMP_DIR}/$(basename "$OUTPUT_NAME").sha256" "$OUTPUT_NAME.sha256"
mv "${TEMP_DIR}/$(basename "$OUTPUT_NAME").sha512" "$OUTPUT_NAME.sha512"
# Cleanup
rm -rf "$TEMP_DIR"
echo "✅ Checksums generated successfully"
- name: Upload build artifact
uses: actions/upload-artifact@v6
with:
name: binaries
path: ${{ steps.build.outputs.binary_name }}
name: ${{ steps.build.outputs.binary_name }}
path: |
${{ steps.build.outputs.binary_name }}
${{ steps.build.outputs.binary_name }}.sha256
${{ steps.build.outputs.binary_name }}.sha512
retention-days: 1
checksums:
@@ -161,7 +218,6 @@ jobs:
- name: Download all artifacts
uses: actions/download-artifact@v7
with:
name: binaries
path: ./binaries
- name: Generate checksums
@@ -170,19 +226,36 @@ jobs:
# Debug: Show the downloaded structure
echo "📁 Downloaded artifact structure:"
ls -la
find . -type f -name "soundtouch-cli-*"
# Generate SHA256 checksums
if ls soundtouch-cli-* 1> /dev/null 2>&1; then
sha256sum soundtouch-cli-* > checksums.sha256
sha512sum soundtouch-cli-* > checksums.sha512
# Create a collection directory to avoid naming conflicts
mkdir -p release-files
echo "📋 Generated checksums:"
# Move all files from subdirectories to the collection directory
find . -mindepth 2 -type f -name "soundtouch-cli-*" -exec mv {} release-files/ \;
# Remove empty directories
find . -type d -empty -delete
# Move to the collection directory for the rest of the processing
cd release-files
# Debug: Show flattened structure
echo "📁 Flattened structure:"
ls -la soundtouch-cli-* || echo "No files found matching pattern"
# Generate combined checksums (exclude individual .sha256/.sha512 files)
if ls soundtouch-cli-v* 1> /dev/null 2>&1; then
# Only checksum the actual binaries, not the .sha256/.sha512 files
ls soundtouch-cli-v* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256
ls soundtouch-cli-v* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512
echo "📋 Generated combined checksums:"
cat checksums.sha256
# Verify all expected files are present
# Verify all expected files are present (binaries only, not checksum files)
EXPECTED_COUNT=7 # Based on build matrix
ACTUAL_COUNT=$(ls soundtouch-cli-* | wc -l)
ACTUAL_COUNT=$(ls soundtouch-cli-v* | grep -v '\.sha256$' | grep -v '\.sha512$' | wc -l)
if [[ $ACTUAL_COUNT -ne $EXPECTED_COUNT ]]; then
echo "❌ Expected $EXPECTED_COUNT binaries, found $ACTUAL_COUNT"
@@ -203,15 +276,17 @@ jobs:
with:
name: checksums
path: |
binaries/checksums.sha256
binaries/checksums.sha512
binaries/release-files/checksums.sha256
binaries/release-files/checksums.sha512
binaries/release-files/*.sha256
binaries/release-files/*.sha512
retention-days: 1
- name: Upload all release assets
uses: actions/upload-artifact@v6
with:
name: release-assets
path: binaries/
path: binaries/release-files/
retention-days: 1
create_release:
@@ -329,7 +404,34 @@ jobs:
## 🔐 Checksums
SHA256 checksums are provided in \`checksums.sha256\` to verify download integrity.
Multiple checksum options are provided for download verification:
### Combined Checksums (Recommended)
- \`checksums.sha256\` - SHA256 checksums for all binaries
- \`checksums.sha512\` - SHA512 checksums for all binaries
\`\`\`bash
# Download any binary + combined checksums
curl -L -O https://github.com/.../soundtouch-cli-v$TAG_NAME-linux-amd64
curl -L -O https://github.com/.../checksums.sha256
# Verify your specific download
sha256sum -c checksums.sha256 --ignore-missing
\`\`\`
### Individual Checksums (Per Binary)
Each binary also has its own dedicated checksum files:
- \`soundtouch-cli-v$TAG_NAME-platform.sha256\`
- \`soundtouch-cli-v$TAG_NAME-platform.sha512\`
\`\`\`bash
# Download binary + its individual checksum
curl -L -O https://github.com/.../soundtouch-cli-v$TAG_NAME-linux-amd64
curl -L -O https://github.com/.../soundtouch-cli-v$TAG_NAME-linux-amd64.sha256
# Verify with individual checksum
sha256sum -c soundtouch-cli-v$TAG_NAME-linux-amd64.sha256
\`\`\`
## 🤝 Contributing
@@ -351,7 +453,7 @@ jobs:
draft: false
prerelease: ${{ needs.validate.outputs.is_prerelease == 'true' }}
files: |
release-assets/soundtouch-cli-*
release-assets/soundtouch-cli-v*
release-assets/checksums.sha256
release-assets/checksums.sha512
fail_on_unmatched_files: true
@@ -376,7 +478,7 @@ jobs:
with:
tag_name: ${{ github.event.release.tag_name }}
files: |
release-assets/soundtouch-cli-*
release-assets/soundtouch-cli-v*
release-assets/checksums.sha256
release-assets/checksums.sha512
fail_on_unmatched_files: true
+1 -1
View File
@@ -26,7 +26,7 @@ BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S')
COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
# Linker flags
LDFLAGS=-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME) -X main.Commit=$(COMMIT)
LDFLAGS=-X main.version=$(VERSION) -X main.date=$(BUILD_TIME) -X main.commit=$(COMMIT)
all: check build
+68 -60
View File
@@ -14,6 +14,72 @@ import (
"github.com/hashicorp/mdns"
)
func displayResults(services []ServiceInfo) {
if len(services) == 0 {
fmt.Println("No services found.")
fmt.Println()
fmt.Println("This could mean:")
fmt.Println("- No mDNS services on network")
fmt.Println("- Network blocks multicast traffic")
fmt.Println("- Firewall blocks mDNS port 5353")
fmt.Println("- Try different service types or increase timeout")
return
}
// Group services by type for better display
serviceGroups := make(map[string][]ServiceInfo)
for _, s := range services {
serviceGroups[s.ServiceType] = append(serviceGroups[s.ServiceType], s)
}
// Display grouped services
for serviceType, serviceList := range serviceGroups {
fmt.Printf("Service Type: %s\n", serviceType)
fmt.Printf(" Found %d instance(s):\n", len(serviceList))
for i, s := range serviceList {
fmt.Printf(" %d. %s\n", i+1, s.Name)
if s.Host != "" {
fmt.Printf(" Host: %s\n", s.Host)
}
if s.IPv4 != "" {
fmt.Printf(" IPv4: %s\n", s.IPv4)
}
if s.IPv6 != "" {
fmt.Printf(" IPv6: %s\n", s.IPv6)
}
if s.Port > 0 {
fmt.Printf(" Port: %d\n", s.Port)
}
if len(s.TxtRecords) > 0 {
fmt.Printf(" TXT Records: %v\n", s.TxtRecords)
}
}
fmt.Println()
}
}
func showSuggestions(service string) {
if service == "_services._dns-sd._udp" {
fmt.Println("Common services to look for SoundTouch devices:")
fmt.Println("- _soundtouch._tcp.local.")
fmt.Println("- _http._tcp.local.")
fmt.Println("- _upnp._tcp.local.")
fmt.Println("- _device-info._tcp.local.")
fmt.Println()
fmt.Println("Try scanning specific services:")
fmt.Println(" ./mdns-scanner -service _soundtouch._tcp -v")
fmt.Println(" ./mdns-scanner -service _http._tcp -v")
}
}
func main() {
verbose := flag.Bool("verbose", false, "Enable verbose logging")
timeout := flag.Duration("timeout", 10*time.Second, "Discovery timeout")
@@ -107,68 +173,10 @@ done:
})
// Display results
if len(services) == 0 {
fmt.Println("No services found.")
fmt.Println()
fmt.Println("This could mean:")
fmt.Println("- No mDNS services on network")
fmt.Println("- Network blocks multicast traffic")
fmt.Println("- Firewall blocks mDNS port 5353")
fmt.Println("- Try different service types or increase timeout")
} else {
// Group services by type for better display
serviceGroups := make(map[string][]ServiceInfo)
for _, service := range services {
serviceType := service.ServiceType
serviceGroups[serviceType] = append(serviceGroups[serviceType], service)
}
// Display grouped services
for serviceType, serviceList := range serviceGroups {
fmt.Printf("Service Type: %s\n", serviceType)
fmt.Printf(" Found %d instance(s):\n", len(serviceList))
for i, service := range serviceList {
fmt.Printf(" %d. %s\n", i+1, service.Name)
if service.Host != "" {
fmt.Printf(" Host: %s\n", service.Host)
}
if service.IPv4 != "" {
fmt.Printf(" IPv4: %s\n", service.IPv4)
}
if service.IPv6 != "" {
fmt.Printf(" IPv6: %s\n", service.IPv6)
}
if service.Port > 0 {
fmt.Printf(" Port: %d\n", service.Port)
}
if len(service.TxtRecords) > 0 {
fmt.Printf(" TXT Records: %v\n", service.TxtRecords)
}
}
fmt.Println()
}
}
displayResults(services)
// Show suggestions for common SoundTouch-related services
if *service == "_services._dns-sd._udp" {
fmt.Println("Common services to look for SoundTouch devices:")
fmt.Println("- _soundtouch._tcp.local.")
fmt.Println("- _http._tcp.local.")
fmt.Println("- _upnp._tcp.local.")
fmt.Println("- _device-info._tcp.local.")
fmt.Println()
fmt.Println("Try scanning specific services:")
fmt.Println(" ./mdns-scanner -service _soundtouch._tcp -v")
fmt.Println(" ./mdns-scanner -service _http._tcp -v")
}
showSuggestions(*service)
}
type ServiceInfo struct {
+39 -35
View File
@@ -3,9 +3,47 @@ package main
import (
"fmt"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/urfave/cli/v2"
)
func printNetworkInterface(i int, iface *models.NetworkInterface) {
fmt.Printf("\n Interface %d:\n", i+1)
fmt.Printf(" Type: %s\n", iface.GetType())
if iface.GetName() != "" {
fmt.Printf(" Name: %s\n", iface.GetName())
}
if iface.GetIPAddress() != "" {
fmt.Printf(" IP Address: %s\n", iface.GetIPAddress())
}
if iface.GetMacAddress() != "" {
fmt.Printf(" MAC Address: %s\n", iface.GetMacAddress())
}
fmt.Printf(" State: %s\n", iface.GetStateDescription())
if iface.IsWiFi() {
if iface.GetSSID() != "" {
fmt.Printf(" SSID: %s\n", iface.GetSSID())
}
if iface.GetSignal() != "" {
fmt.Printf(" Signal: %s (%d%%)\n", iface.GetSignalDescription(), iface.GetSignalQuality())
}
if iface.GetFrequencyKHz() > 0 {
fmt.Printf(" Frequency: %s (%s)\n", iface.FormatFrequency(), iface.GetFrequencyBand())
}
if iface.GetMode() != "" {
fmt.Printf(" Mode: %s\n", iface.GetModeDescription())
}
}
}
// getNetworkInfo retrieves network information from the device
func getNetworkInfo(c *cli.Context) error {
clientConfig := GetClientConfig(c)
@@ -38,41 +76,7 @@ func getNetworkInfo(c *cli.Context) error {
fmt.Printf(" Interfaces (%d):\n", len(interfaces))
for i := range interfaces {
iface := &interfaces[i]
fmt.Printf("\n Interface %d:\n", i+1)
fmt.Printf(" Type: %s\n", iface.GetType())
if iface.GetName() != "" {
fmt.Printf(" Name: %s\n", iface.GetName())
}
if iface.GetIPAddress() != "" {
fmt.Printf(" IP Address: %s\n", iface.GetIPAddress())
}
if iface.GetMacAddress() != "" {
fmt.Printf(" MAC Address: %s\n", iface.GetMacAddress())
}
fmt.Printf(" State: %s\n", iface.GetStateDescription())
if iface.IsWiFi() {
if iface.GetSSID() != "" {
fmt.Printf(" SSID: %s\n", iface.GetSSID())
}
if iface.GetSignal() != "" {
fmt.Printf(" Signal: %s (%d%%)\n", iface.GetSignalDescription(), iface.GetSignalQuality())
}
if iface.GetFrequencyKHz() > 0 {
fmt.Printf(" Frequency: %s (%s)\n", iface.FormatFrequency(), iface.GetFrequencyBand())
}
if iface.GetMode() != "" {
fmt.Printf(" Mode: %s\n", iface.GetModeDescription())
}
}
printNetworkInterface(i, &interfaces[i])
}
// Show active connections summary
+22 -20
View File
@@ -4,9 +4,30 @@ import (
"fmt"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/urfave/cli/v2"
)
func printSource(source models.SourceItem) {
fmt.Printf(" • %s", source.GetDisplayName())
if source.SourceAccount != "" && source.SourceAccount != source.Source {
fmt.Printf(" (%s)", source.SourceAccount)
}
var attributes []string
if source.IsLocalSource() {
attributes = append(attributes, "Local")
attributes = append(attributes, "Available")
}
if len(attributes) > 0 {
fmt.Printf(" [%s]", strings.Join(attributes, ", "))
}
fmt.Println()
}
// listSources handles listing available audio sources
func listSources(c *cli.Context) error {
clientConfig := GetClientConfig(c)
@@ -32,26 +53,7 @@ func listSources(c *cli.Context) error {
fmt.Printf(" Ready Sources:\n")
for _, source := range availableSources {
fmt.Printf(" • %s", source.GetDisplayName())
if source.SourceAccount != "" && source.SourceAccount != source.Source {
fmt.Printf(" (%s)", source.SourceAccount)
}
var attributes []string
if source.IsLocalSource() {
attributes = append(attributes, "Local")
}
if source.IsLocalSource() {
attributes = append(attributes, "Available")
}
if len(attributes) > 0 {
fmt.Printf(" [%s]", strings.Join(attributes, ", "))
}
fmt.Println()
printSource(source)
}
}
+12
View File
@@ -3,6 +3,7 @@ package main
import (
"fmt"
"net"
"runtime"
"strconv"
"strings"
"time"
@@ -146,3 +147,14 @@ func PrintError(message string) {
func PrintWarning(message string) {
fmt.Printf("⚠️ %s\n", message)
}
// showVersionInfo displays detailed version information including build details
func showVersionInfo(_ *cli.Context) error {
fmt.Printf("soundtouch-cli version %s\n", version)
fmt.Printf("Build commit: %s\n", commit)
fmt.Printf("Build date: %s\n", date)
fmt.Printf("Go version: %s\n", runtime.Version())
fmt.Printf("Platform: %s/%s\n", runtime.GOOS, runtime.GOARCH)
return nil
}
+15 -1
View File
@@ -7,6 +7,13 @@ import (
"github.com/urfave/cli/v2"
)
// Build-time variables injected via ldflags
var (
version = "dev"
commit = "unknown"
date = "unknown"
)
func main() {
app := &cli.App{
Name: "soundtouch-cli",
@@ -14,7 +21,7 @@ func main() {
Description: `A comprehensive CLI tool for interacting with Bose SoundTouch devices.
Supports device discovery, playback control, volume/bass/balance adjustment,
source selection, zone management, and more.`,
Version: "1.0.0",
Version: version,
Authors: []*cli.Author{
{
Name: "SoundTouch CLI Contributors",
@@ -23,6 +30,13 @@ func main() {
},
Flags: CommonFlags,
Commands: []*cli.Command{
// Version commands
{
Name: "version",
Aliases: []string{"v"},
Usage: "Show detailed version information",
Action: showVersionInfo,
},
// Discovery commands
{
Name: "discover",
+225 -190
View File
@@ -43,6 +43,88 @@ func parseHostPort(hostPort string, defaultPort int) (string, int) {
return hostPort, defaultPort
}
func parseFilters(eventFilter string) map[string]bool {
validFilters := map[string]bool{
"nowPlaying": true, "volume": true, "connection": true,
"preset": true, "zone": true, "bass": true,
}
if eventFilter == "" {
return nil
}
filters := make(map[string]bool)
filterList := strings.Split(eventFilter, ",")
for _, f := range filterList {
f = strings.TrimSpace(f)
if !validFilters[f] {
fmt.Printf("Invalid filter '%s'. Valid filters: nowPlaying, volume, connection, preset, zone, bass\n", f)
os.Exit(1)
}
filters[f] = true
}
return filters
}
func discoverDevice(discoverFlag bool, hostPort string, defaultPort int) (string, int, error) {
if hostPort != "" && !discoverFlag {
deviceHost, devicePort := parseHostPort(hostPort, defaultPort)
fmt.Printf("Connecting to: %s:%d\n", deviceHost, devicePort)
return deviceHost, devicePort, nil
}
fmt.Println("Discovering SoundTouch devices...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cfg := &config.Config{
DiscoveryTimeout: 10 * time.Second,
CacheEnabled: false,
}
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
devices, err := discoveryService.DiscoverDevices(ctx)
if err != nil || len(devices) == 0 {
if err != nil {
return "", 0, fmt.Errorf("discovery failed: %w", err)
}
return "", 0, fmt.Errorf("no SoundTouch devices found")
}
device := devices[0]
fmt.Printf("Found %d device(s), connecting to: %s (%s:%d)\n",
len(devices), device.Name, device.Host, device.Port)
return device.Host, device.Port, nil
}
func setupWebSocket(soundTouchClient *client.Client, reconnect, verbose bool) *client.WebSocketClient {
wsConfig := &client.WebSocketConfig{
ReconnectInterval: 5 * time.Second,
MaxReconnectAttempts: 0, // Unlimited if reconnect enabled
PingInterval: 30 * time.Second,
PongTimeout: 10 * time.Second,
ReadBufferSize: 2048,
WriteBufferSize: 2048,
}
if verbose {
wsConfig.Logger = &VerboseLogger{}
}
if !reconnect {
wsConfig.MaxReconnectAttempts = 1
}
return soundTouchClient.NewWebSocketClient(wsConfig)
}
func main() {
var (
host = flag.String("host", "", "SoundTouch device host/IP address (can include port like host:8090)")
@@ -64,69 +146,13 @@ func main() {
}
// Validate filter if provided
validFilters := map[string]bool{
"nowPlaying": true, "volume": true, "connection": true,
"preset": true, "zone": true, "bass": true,
}
var filters map[string]bool
if *eventFilter != "" {
filters = make(map[string]bool)
filterList := strings.Split(*eventFilter, ",")
for _, f := range filterList {
f = strings.TrimSpace(f)
if !validFilters[f] {
fmt.Printf("Invalid filter '%s'. Valid filters: nowPlaying, volume, connection, preset, zone, bass\n", f)
os.Exit(1)
}
filters[f] = true
}
}
var (
deviceHost string
devicePort int
)
filters := parseFilters(*eventFilter)
// Discover devices if no host specified or discover flag used
if *host == "" || *discover {
fmt.Println("Discovering SoundTouch devices...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Create unified discovery service
cfg := &config.Config{
DiscoveryTimeout: 10 * time.Second,
CacheEnabled: false,
}
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
devices, err := discoveryService.DiscoverDevices(ctx)
if err != nil {
fmt.Printf("Discovery failed: %v\n", err)
return
}
if len(devices) == 0 {
fmt.Println("No SoundTouch devices found")
return
}
// Use first discovered device
device := devices[0]
deviceHost = device.Host
devicePort = device.Port
fmt.Printf("Found %d device(s), connecting to: %s (%s:%d)\n",
len(devices), device.Name, device.Host, device.Port)
} else {
// Parse provided host
deviceHost, devicePort = parseHostPort(*host, *port)
fmt.Printf("Connecting to: %s:%d\n", deviceHost, devicePort)
deviceHost, devicePort, err := discoverDevice(*discover, *host, *port)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
// Create client
@@ -156,24 +182,7 @@ func main() {
deviceInfo.Name, deviceInfo.Type, macAddress)
// Create WebSocket client
wsConfig := &client.WebSocketConfig{
ReconnectInterval: 5 * time.Second,
MaxReconnectAttempts: 0, // Unlimited if reconnect enabled
PingInterval: 30 * time.Second,
PongTimeout: 10 * time.Second,
ReadBufferSize: 2048,
WriteBufferSize: 2048,
}
if *verbose {
wsConfig.Logger = &VerboseLogger{}
}
if !*reconnect {
wsConfig.MaxReconnectAttempts = 1
}
wsClient := soundTouchClient.NewWebSocketClient(wsConfig)
wsClient := setupWebSocket(soundTouchClient, *reconnect, *verbose)
// Set up event handlers
setupEventHandlers(wsClient, filters, *verbose)
@@ -181,9 +190,10 @@ func main() {
// Connect to WebSocket
fmt.Println("Connecting to WebSocket...")
err = wsClient.ConnectWithConfig(wsConfig)
err = wsClient.Connect()
if err != nil {
fmt.Printf("Failed to connect to WebSocket: %v\n", err)
return
}
@@ -243,150 +253,175 @@ func main() {
fmt.Println("Disconnected successfully")
}
func handleNowPlaying(event *models.NowPlayingUpdatedEvent, verbose bool) {
fmt.Printf("\n🎵 Now Playing Update [%s]:\n", event.DeviceID)
np := &event.NowPlaying
if np.IsEmpty() {
fmt.Println(" ⏹️ Nothing playing")
return
}
fmt.Printf(" 🎵 %s\n", np.GetDisplayTitle())
if artist := np.GetDisplayArtist(); artist != "" {
fmt.Printf(" 👤 %s\n", artist)
}
if np.Album != "" {
fmt.Printf(" 💿 %s\n", np.Album)
}
fmt.Printf(" 📻 Source: %s\n", np.Source)
fmt.Printf(" ▶️ Status: %s\n", np.PlayStatus.String())
if np.HasTimeInfo() {
fmt.Printf(" ⏱️ Duration: %s\n", np.FormatDuration())
}
if np.ShuffleSetting != "" {
fmt.Printf(" 🔀 Shuffle: %s\n", np.ShuffleSetting.String())
}
if np.RepeatSetting != "" {
fmt.Printf(" 🔁 Repeat: %s\n", np.RepeatSetting.String())
}
if verbose {
fmt.Printf(" 📱 Raw Source: %s, Account: %s\n", np.Source, np.SourceAccount)
if np.Art != nil && np.Art.URL != "" {
fmt.Printf(" 🖼️ Artwork: %s\n", np.Art.URL)
}
}
}
func handleVolume(event *models.VolumeUpdatedEvent, verbose bool) {
vol := &event.Volume
fmt.Printf("\n🔊 Volume Update [%s]:\n", event.DeviceID)
if vol.IsMuted() {
fmt.Println(" 🔇 Muted")
} else {
fmt.Printf(" 🔊 Level: %d\n", vol.ActualVolume)
if vol.TargetVolume != vol.ActualVolume {
fmt.Printf(" 🎯 Target: %d\n", vol.TargetVolume)
}
fmt.Printf(" 📊 %s\n", models.GetVolumeLevelName(vol.ActualVolume))
}
if verbose {
fmt.Printf(" 📱 Sync: %v\n", vol.IsVolumeSync())
}
}
func handleConnection(event *models.ConnectionStateUpdatedEvent) {
cs := &event.ConnectionState
fmt.Printf("\n🌐 Connection Update [%s]:\n", event.DeviceID)
if cs.IsConnected() {
fmt.Println(" ✅ Connected")
} else {
fmt.Printf(" ❌ State: %s\n", cs.State)
}
if cs.Signal != "" {
fmt.Printf(" 📶 Signal: %s\n", cs.GetSignalStrength())
}
}
func handlePreset(event *models.PresetUpdatedEvent, verbose bool) {
preset := &event.Preset
fmt.Printf("\n📻 Preset Update [%s]:\n", event.DeviceID)
fmt.Printf(" 📻 Preset: %d\n", preset.ID)
if preset.ContentItem != nil {
fmt.Printf(" 🎵 %s\n", preset.ContentItem.ItemName)
fmt.Printf(" 📻 Source: %s\n", preset.ContentItem.Source)
}
if verbose {
fmt.Printf(" 📱 Raw preset data: ID=%d\n", preset.ID)
}
}
func handleZone(event *models.ZoneUpdatedEvent) {
zone := &event.Zone
fmt.Printf("\n🏠 Zone Update [%s]:\n", event.DeviceID)
fmt.Printf(" 👑 Master: %s\n", zone.Master)
if len(zone.Members) > 0 {
fmt.Printf(" 👥 Members (%d):\n", len(zone.Members))
for i, member := range zone.Members {
fmt.Printf(" %d. %s (%s)\n", i+1, member.DeviceID, member.IP)
}
} else {
fmt.Println(" 👤 Single device (no zone)")
}
}
func handleBass(event *models.BassUpdatedEvent) {
bass := &event.Bass
fmt.Printf("\n🎵 Bass Update [%s]:\n", event.DeviceID)
fmt.Printf(" 🎚️ Level: %d\n", bass.ActualBass)
if bass.TargetBass != bass.ActualBass {
fmt.Printf(" 🎯 Target: %d\n", bass.TargetBass)
}
levelDesc := "Neutral"
if bass.ActualBass > 0 {
levelDesc = "Boosted"
} else if bass.ActualBass < 0 {
levelDesc = "Reduced"
}
fmt.Printf(" 📊 %s\n", levelDesc)
}
func setupEventHandlers(wsClient *client.WebSocketClient, filters map[string]bool, verbose bool) {
// Now Playing events
if filters == nil || filters["nowPlaying"] {
wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) {
fmt.Printf("\n🎵 Now Playing Update [%s]:\n", event.DeviceID)
np := &event.NowPlaying
if np.IsEmpty() {
fmt.Println(" ⏹️ Nothing playing")
} else {
fmt.Printf(" 🎵 %s\n", np.GetDisplayTitle())
if artist := np.GetDisplayArtist(); artist != "" {
fmt.Printf(" 👤 %s\n", artist)
}
if np.Album != "" {
fmt.Printf(" 💿 %s\n", np.Album)
}
fmt.Printf(" 📻 Source: %s\n", np.Source)
fmt.Printf(" ▶️ Status: %s\n", np.PlayStatus.String())
if np.HasTimeInfo() {
fmt.Printf(" ⏱️ Duration: %s\n", np.FormatDuration())
}
if np.ShuffleSetting != "" {
fmt.Printf(" 🔀 Shuffle: %s\n", np.ShuffleSetting.String())
}
if np.RepeatSetting != "" {
fmt.Printf(" 🔁 Repeat: %s\n", np.RepeatSetting.String())
}
}
if verbose {
fmt.Printf(" 📱 Raw Source: %s, Account: %s\n", np.Source, np.SourceAccount)
if np.Art != nil && np.Art.URL != "" {
fmt.Printf(" 🖼️ Artwork: %s\n", np.Art.URL)
}
}
handleNowPlaying(event, verbose)
})
}
// Volume events
if filters == nil || filters["volume"] {
wsClient.OnVolumeUpdated(func(event *models.VolumeUpdatedEvent) {
vol := &event.Volume
fmt.Printf("\n🔊 Volume Update [%s]:\n", event.DeviceID)
if vol.IsMuted() {
fmt.Println(" 🔇 Muted")
} else {
fmt.Printf(" 🔊 Level: %d\n", vol.ActualVolume)
if vol.TargetVolume != vol.ActualVolume {
fmt.Printf(" 🎯 Target: %d\n", vol.TargetVolume)
}
fmt.Printf(" 📊 %s\n", models.GetVolumeLevelName(vol.ActualVolume))
}
if verbose {
fmt.Printf(" 📱 Sync: %v\n", vol.IsVolumeSync())
}
handleVolume(event, verbose)
})
}
// Connection state events
if filters == nil || filters["connection"] {
wsClient.OnConnectionState(func(event *models.ConnectionStateUpdatedEvent) {
cs := &event.ConnectionState
fmt.Printf("\n🌐 Connection Update [%s]:\n", event.DeviceID)
if cs.IsConnected() {
fmt.Println(" ✅ Connected")
} else {
fmt.Printf(" ❌ State: %s\n", cs.State)
}
if cs.Signal != "" {
fmt.Printf(" 📶 Signal: %s\n", cs.GetSignalStrength())
}
handleConnection(event)
})
}
// Preset events
if filters == nil || filters["preset"] {
wsClient.OnPresetUpdated(func(event *models.PresetUpdatedEvent) {
preset := &event.Preset
fmt.Printf("\n📻 Preset Update [%s]:\n", event.DeviceID)
fmt.Printf(" 📻 Preset: %d\n", preset.ID)
if preset.ContentItem != nil {
fmt.Printf(" 🎵 %s\n", preset.ContentItem.ItemName)
fmt.Printf(" 📻 Source: %s\n", preset.ContentItem.Source)
}
if verbose {
fmt.Printf(" 📱 Raw preset data: ID=%d\n", preset.ID)
}
handlePreset(event, verbose)
})
}
// Zone/Multiroom events
if filters == nil || filters["zone"] {
wsClient.OnZoneUpdated(func(event *models.ZoneUpdatedEvent) {
zone := &event.Zone
fmt.Printf("\n🏠 Zone Update [%s]:\n", event.DeviceID)
fmt.Printf(" 👑 Master: %s\n", zone.Master)
if len(zone.Members) > 0 {
fmt.Printf(" 👥 Members (%d):\n", len(zone.Members))
for i, member := range zone.Members {
fmt.Printf(" %d. %s (%s)\n", i+1, member.DeviceID, member.IP)
}
} else {
fmt.Println(" 👤 Single device (no zone)")
}
handleZone(event)
})
}
// Bass events
if filters == nil || filters["bass"] {
wsClient.OnBassUpdated(func(event *models.BassUpdatedEvent) {
bass := &event.Bass
fmt.Printf("\n🎵 Bass Update [%s]:\n", event.DeviceID)
fmt.Printf(" 🎚️ Level: %d\n", bass.ActualBass)
if bass.TargetBass != bass.ActualBass {
fmt.Printf(" 🎯 Target: %d\n", bass.TargetBass)
}
levelDesc := "Neutral"
if bass.ActualBass > 0 {
levelDesc = "Boosted"
} else if bass.ActualBass < 0 {
levelDesc = "Reduced"
}
fmt.Printf(" 📊 %s\n", levelDesc)
handleBass(event)
})
}
+156
View File
@@ -0,0 +1,156 @@
// Package bose-soundtouch provides a comprehensive Go library and CLI tool for controlling Bose SoundTouch devices.
//
// This library implements the complete Bose SoundTouch Web API, enabling programmatic control
// of SoundTouch speakers including playback control, volume management, source selection,
// multiroom zone management, and real-time event monitoring via WebSocket connections.
//
// # Quick Start
//
// Install the library:
//
// go get github.com/gesellix/bose-soundtouch
//
// Basic usage example:
//
// package main
//
// import (
// "fmt"
// "log"
//
// "github.com/gesellix/bose-soundtouch/pkg/client"
// )
//
// func main() {
// // Create a client for your SoundTouch device
// config := &client.Config{
// Host: "192.168.1.100",
// Port: 8090,
// }
// client := client.NewClient(config)
//
// // Get device information
// info, err := client.GetInfo()
// if err != nil {
// log.Fatal(err)
// }
// fmt.Printf("Device: %s\n", info.Name)
//
// // Control playback
// err = client.Play()
// if err != nil {
// log.Fatal(err)
// }
//
// // Set volume
// err = client.SetVolume(50)
// if err != nil {
// log.Fatal(err)
// }
// }
//
// # Device Discovery
//
// Automatically discover SoundTouch devices on your network:
//
// import "github.com/gesellix/bose-soundtouch/pkg/discovery"
//
// // Discover devices using UPnP/SSDP
// service := discovery.NewService(5*time.Second)
// devices, err := service.DiscoverDevices(ctx)
// if err != nil {
// log.Fatal(err)
// }
//
// for _, device := range devices {
// fmt.Printf("Found device: %s at %s\n", device.Name, device.Host)
// }
//
// # Real-time Events
//
// Monitor device state changes in real-time using WebSocket connections:
//
// // Subscribe to device events
// events, err := client.SubscribeToEvents(ctx)
// if err != nil {
// log.Fatal(err)
// }
//
// for event := range events {
// switch e := event.(type) {
// case *models.NowPlayingUpdated:
// fmt.Printf("Now playing: %s by %s\n", e.Track, e.Artist)
// case *models.VolumeUpdated:
// fmt.Printf("Volume changed to: %d\n", e.ActualVolume)
// }
// }
//
// # Multiroom Zone Management
//
// Create and manage multiroom zones:
//
// // Create a zone with multiple speakers
// zone := &models.Zone{
// Master: "192.168.1.100",
// Members: []models.ZoneMember{
// {IPAddress: "192.168.1.101"},
// {IPAddress: "192.168.1.102"},
// },
// }
// err = client.SetZone(zone)
//
// # CLI Tool
//
// The package includes a comprehensive CLI tool for device control:
//
// # Install the CLI
// go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-cli@latest
//
// # Discover devices
// soundtouch-cli discover devices
//
// # Control a device
// soundtouch-cli --host 192.168.1.100 play start
// soundtouch-cli --host 192.168.1.100 volume set --level 50
// soundtouch-cli --host 192.168.1.100 source select --source SPOTIFY
//
// # Supported Features
//
// - ✅ Device Information & Capabilities
// - ✅ Playback Control (Play/Pause/Stop/Next/Previous)
// - ✅ Volume, Bass, and Balance Control
// - ✅ Source Selection (Spotify, Bluetooth, AUX, etc.)
// - ✅ Preset Management
// - ✅ Clock/Time Management
// - ✅ Network Information
// - ✅ Real-time WebSocket Events
// - ✅ Multiroom Zone Management
// - ✅ Device Discovery (UPnP/SSDP and mDNS)
// - ✅ Cross-platform Support (Windows, macOS, Linux)
//
// # Package Structure
//
// - client: HTTP client for SoundTouch Web API
// - discovery: Device discovery using UPnP/SSDP and mDNS
// - models: Data structures for API requests/responses
// - config: Configuration management
// - cmd/soundtouch-cli: Command-line interface tool
//
// # Hardware Compatibility
//
// This library has been tested with real Bose SoundTouch hardware and supports
// all SoundTouch-compatible devices including:
// - SoundTouch 10, 20, 30 series
// - SoundTouch Portable
// - Wave SoundTouch music system
// - And other SoundTouch-enabled Bose speakers
//
// # Implementation Notes
//
// This implementation is based on the official Bose SoundTouch Web API documentation
// and provides 90% coverage of all available endpoints. It is an independent project
// and is not affiliated with or endorsed by Bose Corporation.
//
// For detailed API documentation, examples, and advanced usage patterns, visit:
// https://pkg.go.dev/github.com/gesellix/bose-soundtouch
package main
+9 -9
View File
@@ -8,7 +8,7 @@ This cookbook provides practical solutions to common SoundTouch integration chal
- [Device Management](#device-management)
- [Playback Control](#playback-control)
- [Volume & Audio](#volume--audio)
- [Volume Audio](#volume-audio)
- [Real-time Monitoring](#real-time-monitoring)
- [Multiroom Coordination](#multiroom-coordination)
- [Error Handling](#error-handling)
@@ -17,7 +17,7 @@ This cookbook provides practical solutions to common SoundTouch integration chal
---
## 🖥️ **Device Management**
## Device Management
### Recipe: Robust Device Discovery
@@ -185,7 +185,7 @@ func (dm *DeviceMonitor) IsHealthy() bool {
---
## 🎵 **Playback Control**
## Playback Control
### Recipe: Smart Play/Pause Toggle
@@ -292,7 +292,7 @@ func (pn *PlaylistNavigator) GetTrackInfo() (string, error) {
---
## 🔊 **Volume & Audio**
## Volume Audio
### Recipe: Gradual Volume Transitions
@@ -539,7 +539,7 @@ func (pm *ProfileManager) CreateDefaultProfiles() {
---
## 📡 **Real-time Monitoring**
## Real-time Monitoring
### Recipe: Event-Driven State Manager
@@ -658,7 +658,7 @@ func (aps *AutoPauseSubscriber) OnStateChange(oldState, newState DeviceState) {
---
## 👥 **Multiroom Coordination**
## Multiroom Coordination
### Recipe: Party Mode Controller
@@ -780,7 +780,7 @@ func (pmc *PartyModeController) GetZoneStatus() (string, error) {
---
## ⚠️ **Error Handling**
## Error Handling
### Recipe: Resilient Operation Wrapper
@@ -884,7 +884,7 @@ func (rc *ResilientClient) SelectSource(source, account string) error {
---
## 🚀 **Performance Optimization**
## Performance Optimization
### Recipe: Connection Pool Manager
@@ -1014,7 +1014,7 @@ func (cp *ConnectionPool) Stats() (active int, idle int) {
---
## 🏭 **Production Patterns**
## Production Patterns
### Recipe: Configuration Management
+12 -12
View File
@@ -8,16 +8,16 @@ This guide covers everything you need to know to deploy robust, scalable SoundTo
- [Architecture Considerations](#architecture-considerations)
- [Configuration Management](#configuration-management)
- [Security & Network](#security--network)
- [Monitoring & Logging](#monitoring--logging)
- [Security Network](#security-network)
- [Monitoring Logging](#monitoring-logging)
- [Performance Optimization](#performance-optimization)
- [Error Handling & Recovery](#error-handling--recovery)
- [Error Handling Recovery](#error-handling-recovery)
- [Deployment Strategies](#deployment-strategies)
- [Maintenance & Operations](#maintenance--operations)
- [Maintenance Operations](#maintenance-operations)
---
## 🏗️ **Architecture Considerations**
## Architecture Considerations
### Single-Device Applications
@@ -102,7 +102,7 @@ type ProductionSoundTouchService struct {
---
## ⚙️ **Configuration Management**
## Configuration Management
### Environment-Based Configuration
@@ -210,7 +210,7 @@ func LoadConfigFromFile(path string) (*Config, error) {
---
## 🔒 **Security & Network**
## Security Network
### Network Security
@@ -312,7 +312,7 @@ func loadSecretsFromK8s() (*SecretsConfig, error) {
---
## 📊 **Monitoring & Logging**
## Monitoring Logging
### Structured Logging
@@ -534,7 +534,7 @@ func (hc *HealthChecker) HealthHandler() http.HandlerFunc {
---
## 🚀 **Performance Optimization**
## Performance Optimization
### Connection Pooling
@@ -670,7 +670,7 @@ func (cm *CacheManager) GetDeviceInfo(deviceID string, fetcher func() (*models.D
---
## 🛡️ **Error Handling & Recovery**
## Error Handling Recovery
### Circuit Breaker Pattern
@@ -780,7 +780,7 @@ func (app *Application) Run(ctx context.Context) error {
---
## 🚢 **Deployment Strategies**
## Deployment Strategies
### Docker Deployment
@@ -961,7 +961,7 @@ WantedBy=multi-user.target
---
## 🔧 **Maintenance & Operations**
## Maintenance Operations
### Log Rotation
+141 -1
View File
@@ -1,4 +1,144 @@
// Package client provides HTTP client functionality for interacting with Bose SoundTouch devices.
// Package client provides a comprehensive HTTP client for controlling Bose SoundTouch devices.
//
// This package implements the complete Bose SoundTouch Web API, enabling full programmatic
// control of SoundTouch speakers including playback control, volume management, source
// selection, multiroom zone management, and real-time event monitoring.
//
// # Basic Usage
//
// Create a client and control your SoundTouch device:
//
// config := &client.Config{
// Host: "192.168.1.100",
// Port: 8090,
// Timeout: 10 * time.Second,
// }
// client := client.NewClient(config)
//
// // Get device information
// info, err := client.GetInfo()
// if err != nil {
// log.Fatal(err)
// }
// fmt.Printf("Device: %s (Type: %s)\n", info.Name, info.Type)
//
// // Control playback
// err = client.Play()
// if err != nil {
// log.Fatal(err)
// }
//
// // Adjust volume
// err = client.SetVolume(50)
// if err != nil {
// log.Fatal(err)
// }
//
// # Advanced Features
//
// The client supports all SoundTouch API endpoints:
//
// // Get current playback status
// nowPlaying, err := client.GetNowPlaying()
// if err != nil {
// log.Fatal(err)
// }
// fmt.Printf("Now Playing: %s by %s\n", nowPlaying.Track, nowPlaying.Artist)
//
// // Select audio source
// err = client.SelectSource("SPOTIFY", "")
// if err != nil {
// log.Fatal(err)
// }
//
// // Control bass and balance
// err = client.SetBass(3) // Range: -9 to +9
// if err != nil {
// log.Fatal(err)
// }
//
// err = client.SetBalance(-10) // Range: -50 (left) to +50 (right)
// if err != nil {
// log.Fatal(err)
// }
//
// # Multiroom Zone Management
//
// Create and manage multiroom zones:
//
// // Get current zone configuration
// zone, err := client.GetZone()
// if err != nil {
// log.Fatal(err)
// }
//
// // Create a new zone with multiple speakers
// newZone := &models.ZoneRequest{
// Master: "192.168.1.100",
// Members: []models.MemberEntry{
// {IP: "192.168.1.101"},
// {IP: "192.168.1.102"},
// },
// }
// err = client.SetZone(newZone)
// if err != nil {
// log.Fatal(err)
// }
//
// # Real-time Events
//
// Monitor device state changes using WebSocket connections:
//
// ctx := context.Background()
// events, err := client.SubscribeToEvents(ctx)
// if err != nil {
// log.Fatal(err)
// }
//
// for event := range events {
// switch e := event.(type) {
// case *models.NowPlayingUpdated:
// fmt.Printf("Track changed: %s\n", e.Track)
// case *models.VolumeUpdated:
// fmt.Printf("Volume: %d\n", e.ActualVolume)
// case *models.ConnectionStateUpdated:
// fmt.Printf("Connection: %s\n", e.State)
// }
// }
//
// # Error Handling
//
// The client provides detailed error information:
//
// err := client.SetVolume(150) // Invalid volume
// if err != nil {
// fmt.Printf("Error: %v\n", err) // Will indicate volume out of range
// }
//
// # Configuration
//
// The Config struct supports various options:
//
// config := &client.Config{
// Host: "192.168.1.100",
// Port: 8090,
// Timeout: 15 * time.Second,
// UserAgent: "MyApp/1.0",
// }
//
// # Supported Operations
//
// - Device Information & Capabilities
// - Playback Control (Play/Pause/Stop/Next/Previous/Key commands)
// - Volume Control (Get/Set/Increment/Decrement)
// - Bass Control (-9 to +9 range)
// - Balance Control (-50 to +50 range)
// - Source Selection (Spotify, Bluetooth, AUX, Radio, etc.)
// - Preset Management (Get configured presets)
// - Clock/Time Management
// - Network Information
// - Multiroom Zone Management
// - Real-time WebSocket Event Monitoring
package client
import (
+286
View File
@@ -0,0 +1,286 @@
package client_test
import (
"fmt"
"log"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
// Example demonstrates basic device control operations.
func Example() {
// Create a client for your SoundTouch device
config := &client.Config{
Host: "192.168.1.100",
Port: 8090,
Timeout: 10 * time.Second,
}
c := client.NewClient(config)
// Get device information
info, err := c.GetDeviceInfo()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Device: %s\n", info.Name)
// Control playback
err = c.Play()
if err != nil {
log.Fatal(err)
}
// Set volume to 50%
err = c.SetVolume(50)
if err != nil {
log.Fatal(err)
}
// Example output:
// Device: Living Room Speaker
}
// ExampleClient_GetNowPlaying demonstrates how to get current playback information.
func ExampleClient_GetNowPlaying() {
config := &client.Config{Host: "192.168.1.100"}
c := client.NewClient(config)
nowPlaying, err := c.GetNowPlaying()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Track: %s\n", nowPlaying.Track)
fmt.Printf("Artist: %s\n", nowPlaying.Artist)
fmt.Printf("Album: %s\n", nowPlaying.Album)
fmt.Printf("Source: %s\n", nowPlaying.Source)
// Example output:
// Track: Bohemian Rhapsody
// Artist: Queen
// Album: A Night at the Opera
// Source: SPOTIFY
}
// ExampleClient_SetVolume demonstrates volume control with validation.
func ExampleClient_SetVolume() {
config := &client.Config{Host: "192.168.1.100"}
c := client.NewClient(config)
// Set volume to 75%
err := c.SetVolume(75)
if err != nil {
log.Fatal(err)
}
// Get current volume
volume, err := c.GetVolume()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Volume: %d\n", volume.ActualVolume)
fmt.Printf("Muted: %t\n", volume.MuteEnabled)
// Example output:
// Volume: 75
// Muted: false
}
// ExampleClient_SelectSource demonstrates how to change audio sources.
func ExampleClient_SelectSource() {
config := &client.Config{Host: "192.168.1.100"}
c := client.NewClient(config)
// Switch to Spotify
err := c.SelectSource("SPOTIFY", "")
if err != nil {
log.Fatal(err)
}
// Switch to Bluetooth
err = c.SelectSource("BLUETOOTH", "")
if err != nil {
log.Fatal(err)
}
// Switch to AUX input
err = c.SelectSource("AUX", "")
if err != nil {
log.Fatal(err)
}
fmt.Println("Source changed successfully")
// Example output:
// Source changed successfully
}
// ExampleClient_SetBass demonstrates bass control.
func ExampleClient_SetBass() {
config := &client.Config{Host: "192.168.1.100"}
c := client.NewClient(config)
// Set bass to +3 (range: -9 to +9)
err := c.SetBass(3)
if err != nil {
log.Fatal(err)
}
// Get current bass level
bass, err := c.GetBass()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Bass level: %d\n", bass.ActualBass)
// Example output:
// Bass level: 3
}
// ExampleClient_SetBalance demonstrates balance control.
func ExampleClient_SetBalance() {
config := &client.Config{Host: "192.168.1.100"}
c := client.NewClient(config)
// Set balance slightly to the right (range: -50 to +50)
err := c.SetBalance(10)
if err != nil {
log.Fatal(err)
}
// Get current balance
balance, err := c.GetBalance()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Balance: %d\n", balance.ActualBalance)
// Example output:
// Balance: 10
}
// ExampleClient_SetZone demonstrates multiroom zone management.
func ExampleClient_SetZone() {
config := &client.Config{Host: "192.168.1.100"}
c := client.NewClient(config)
// Create a zone with multiple speakers
zone := &models.ZoneRequest{
Master: "192.168.1.100",
Members: []models.MemberEntry{
{IP: "192.168.1.101"},
{IP: "192.168.1.102"},
},
}
err := c.SetZone(zone)
if err != nil {
log.Fatal(err)
}
fmt.Println("Zone created successfully")
// Example output:
// Zone created successfully
}
// ExampleClient_GetPresets demonstrates how to retrieve configured presets.
func ExampleClient_GetPresets() {
config := &client.Config{Host: "192.168.1.100"}
c := client.NewClient(config)
presets, err := c.GetPresets()
if err != nil {
log.Fatal(err)
}
for _, preset := range presets.Preset {
fmt.Printf("Preset %d: %s (%s)\n", preset.ID, preset.GetDisplayName(), preset.GetSource())
}
// Example output:
// Preset 1: Morning Jazz (SPOTIFY)
// Preset 2: Classic Rock (SPOTIFY)
// Preset 3: NPR News (INTERNET_RADIO)
}
// ExampleClient_NewWebSocketClient demonstrates WebSocket client creation.
func ExampleClient_NewWebSocketClient() {
config := &client.Config{Host: "192.168.1.100"}
c := client.NewClient(config)
// Create WebSocket client for real-time events
wsClient := c.NewWebSocketClient(nil)
// Connect to device WebSocket
err := wsClient.Connect()
if err != nil {
log.Fatal(err)
}
defer func() {
_ = wsClient.Disconnect()
}()
fmt.Printf("WebSocket connected: %t\n", wsClient.IsConnected())
// Example output:
// WebSocket connected: true
}
// ExampleClient_SendKey demonstrates sending key commands.
func ExampleClient_SendKey() {
config := &client.Config{Host: "192.168.1.100"}
c := client.NewClient(config)
// Send various key commands
commands := []string{"PLAY", "PAUSE", "NEXT_TRACK", "PREV_TRACK", "MUTE"}
for _, cmd := range commands {
err := c.SendKey(cmd)
if err != nil {
log.Printf("Failed to send %s: %v", cmd, err)
continue
}
fmt.Printf("Sent command: %s\n", cmd)
}
// Example output:
// Sent command: PLAY
// Sent command: PAUSE
// Sent command: NEXT_TRACK
// Sent command: PREV_TRACK
// Sent command: MUTE
}
// ExampleClient_GetCapabilities demonstrates how to check device capabilities.
func ExampleClient_GetCapabilities() {
config := &client.Config{Host: "192.168.1.100"}
c := client.NewClient(config)
capabilities, err := c.GetCapabilities()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Device supports %d capabilities\n", len(capabilities.Capability))
for _, capability := range capabilities.Capability {
fmt.Printf("- %s (URL: %s)\n", capability.Name, capability.URL)
}
// Example output:
// Device supports 5 capabilities
// - VOLUME (/volume)
// - BASS (/bass)
// - SOURCES (/sources)
// - PRESETS (/presets)
// - ZONE (/getZone)
}
+64 -35
View File
@@ -368,6 +368,68 @@ func (ws *WebSocketClient) handleMessage(data []byte) {
ws.handleEvent(event)
}
func (ws *WebSocketClient) dispatchTypedEvent(handlers *models.WebSocketEventHandlers, eventType models.WebSocketEventType, event *models.WebSocketEvent) bool {
switch eventType {
case models.EventTypeNowPlaying:
if handlers.OnNowPlaying != nil && event.NowPlayingUpdated != nil {
handlers.OnNowPlaying(event.NowPlayingUpdated)
}
return true
case models.EventTypeVolumeUpdated:
if handlers.OnVolumeUpdated != nil && event.VolumeUpdated != nil {
handlers.OnVolumeUpdated(event.VolumeUpdated)
}
return true
case models.EventTypeConnectionState:
if handlers.OnConnectionState != nil && event.ConnectionStateUpdated != nil {
handlers.OnConnectionState(event.ConnectionStateUpdated)
}
return true
case models.EventTypePresetUpdated:
if handlers.OnPresetUpdated != nil && event.PresetUpdated != nil {
handlers.OnPresetUpdated(event.PresetUpdated)
}
return true
default:
return ws.dispatchTypedEventContinued(handlers, eventType, event)
}
}
func (ws *WebSocketClient) dispatchTypedEventContinued(handlers *models.WebSocketEventHandlers, eventType models.WebSocketEventType, event *models.WebSocketEvent) bool {
switch eventType {
case models.EventTypeZoneUpdated:
if handlers.OnZoneUpdated != nil && event.ZoneUpdated != nil {
handlers.OnZoneUpdated(event.ZoneUpdated)
}
return true
case models.EventTypeBassUpdated:
if handlers.OnBassUpdated != nil && event.BassUpdated != nil {
handlers.OnBassUpdated(event.BassUpdated)
}
return true
case models.EventTypeRecentsUpdated:
return true
case models.EventTypeLanguageUpdated:
return true
default:
return false
}
}
// handleEvent dispatches events to appropriate handlers
func (ws *WebSocketClient) handleEvent(event *models.WebSocketEvent) {
ws.mu.RLock()
@@ -378,41 +440,8 @@ func (ws *WebSocketClient) handleEvent(event *models.WebSocketEvent) {
hasKnownEvent := false
for _, eventType := range eventTypes {
hasKnownEvent = true
switch eventType {
case models.EventTypeNowPlaying:
if handlers.OnNowPlaying != nil && event.NowPlayingUpdated != nil {
handlers.OnNowPlaying(event.NowPlayingUpdated)
}
case models.EventTypeVolumeUpdated:
if handlers.OnVolumeUpdated != nil && event.VolumeUpdated != nil {
handlers.OnVolumeUpdated(event.VolumeUpdated)
}
case models.EventTypeConnectionState:
if handlers.OnConnectionState != nil && event.ConnectionStateUpdated != nil {
handlers.OnConnectionState(event.ConnectionStateUpdated)
}
case models.EventTypePresetUpdated:
if handlers.OnPresetUpdated != nil && event.PresetUpdated != nil {
handlers.OnPresetUpdated(event.PresetUpdated)
}
case models.EventTypeZoneUpdated:
if handlers.OnZoneUpdated != nil && event.ZoneUpdated != nil {
handlers.OnZoneUpdated(event.ZoneUpdated)
}
case models.EventTypeBassUpdated:
if handlers.OnBassUpdated != nil && event.BassUpdated != nil {
handlers.OnBassUpdated(event.BassUpdated)
}
default:
hasKnownEvent = false
if ws.dispatchTypedEvent(handlers, eventType, event) {
hasKnownEvent = true
}
}
+204
View File
@@ -0,0 +1,204 @@
package discovery_test
import (
"context"
"fmt"
"log"
"time"
"github.com/gesellix/bose-soundtouch/pkg/config"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
)
// Example demonstrates basic device discovery.
func Example() {
service := discovery.NewService(5 * time.Second)
ctx := context.Background()
// Discover all SoundTouch devices on the network
devices, err := service.DiscoverDevices(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Found %d devices:\n", len(devices))
for _, device := range devices {
fmt.Printf("- %s at %s:%d\n", device.Name, device.Host, device.Port)
}
// Example output:
// Found 2 devices:
// - Living Room at 192.168.1.100:8090
// - Kitchen at 192.168.1.101:8090
}
// ExampleService_DiscoverDevices demonstrates discovering devices with timeout.
func ExampleService_DiscoverDevices() {
service := discovery.NewService(3 * time.Second)
ctx := context.Background()
// Quick discovery with 3 second timeout
devices, err := service.DiscoverDevices(ctx)
if err != nil {
log.Fatal(err)
}
if len(devices) == 0 {
fmt.Println("No SoundTouch devices found")
return
}
// Print detailed device information
for _, device := range devices {
fmt.Printf("Device: %s\n", device.Name)
fmt.Printf(" Address: %s:%d\n", device.Host, device.Port)
fmt.Printf(" Serial: %s\n", device.SerialNo)
fmt.Printf(" Location: %s\n", device.Location)
fmt.Printf(" Host: %s:%d\n", device.Host, device.Port)
fmt.Println()
}
// Example output:
// Device: Living Room
// Address: 192.168.1.100:8090
// Serial: AA123456789
// Location: /device.xml
// Host: 192.168.1.100:8090
//
// Device: Kitchen
// Address: 192.168.1.101:8090
// Serial: BB123456789
// Location: /device.xml
// Host: 192.168.1.101:8090
}
// ExampleUnifiedDiscoveryService_DiscoverDevices demonstrates caching functionality.
func ExampleUnifiedDiscoveryService_DiscoverDevices() {
cfg := &config.Config{
DiscoveryTimeout: 5 * time.Second,
CacheEnabled: true,
CacheTTL: 5 * time.Minute,
}
service := discovery.NewUnifiedDiscoveryService(cfg)
ctx := context.Background()
// First discovery scan
fmt.Println("First scan:")
devices, err := service.DiscoverDevices(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Found %d devices\n", len(devices))
// Second scan (should use cache)
fmt.Println("Second scan (cached):")
devices, err = service.DiscoverDevices(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Found %d devices (from cache)\n", len(devices))
// Example output:
// First scan:
// Found 2 devices
// Second scan (cached):
// Found 2 devices (from cache)
}
// Example_upnpOnlyDiscovery demonstrates UPnP-only discovery.
func Example_upnpOnlyDiscovery() {
service := discovery.NewService(3 * time.Second)
ctx := context.Background()
// Use UPnP/SSDP discovery
devices, err := service.DiscoverDevices(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Printf("UPnP discovered %d devices:\n", len(devices))
for _, device := range devices {
fmt.Printf("- %s at %s:%d\n", device.Name, device.Host, device.Port)
}
// Example output:
// UPnP discovered 1 devices:
// - Living Room at 192.168.1.100:8090
}
// ExampleMDNSDiscoveryService_DiscoverDevices demonstrates mDNS-only discovery.
func ExampleMDNSDiscoveryService_DiscoverDevices() {
service := discovery.NewMDNSDiscoveryService(3 * time.Second)
ctx := context.Background()
// Use only mDNS discovery
devices, err := service.DiscoverDevices(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Printf("mDNS discovered %d devices:\n", len(devices))
for _, device := range devices {
fmt.Printf("- %s at %s:%d\n", device.Name, device.Host, device.Port)
}
// Example output:
// mDNS discovered 1 devices:
// - Kitchen at 192.168.1.101:8090
}
// Example_errorHandling demonstrates proper error handling in discovery.
func Example_errorHandling() {
// Very short timeout to demonstrate timeout handling
service := discovery.NewService(100 * time.Millisecond)
ctx := context.Background()
devices, err := service.DiscoverDevices(ctx)
if err != nil {
fmt.Printf("Discovery error: %v\n", err)
return
}
if len(devices) == 0 {
fmt.Println("No devices found - check network connectivity")
return
}
fmt.Printf("Found %d devices despite short timeout\n", len(devices))
// Example output:
// No devices found - check network connectivity
}
// Example_contextCancellation demonstrates context cancellation.
func Example_contextCancellation() {
// Create a context that cancels after 2 seconds
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
service := discovery.NewService(10 * time.Second)
devices, err := service.DiscoverDevices(ctx)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
fmt.Println("Discovery cancelled due to context timeout")
} else {
fmt.Printf("Discovery error: %v\n", err)
}
return
}
fmt.Printf("Found %d devices before context cancellation\n", len(devices))
// Example output:
// Discovery cancelled due to context timeout
}
+107
View File
@@ -1,3 +1,110 @@
// Package discovery provides automatic network discovery of Bose SoundTouch devices.
//
// This package implements both UPnP/SSDP (Universal Plug and Play) and mDNS/Bonjour
// discovery protocols to automatically find SoundTouch devices on your local network.
// It provides a unified interface that combines both discovery methods for maximum
// device detection reliability.
//
// # Basic Usage
//
// Discover all SoundTouch devices on your network:
//
// import (
// "context"
// "time"
// "github.com/gesellix/bose-soundtouch/pkg/discovery"
// )
//
// ctx := context.Background()
// timeout := 5 * time.Second
//
// devices, err := discovery.DiscoverDevices(ctx, timeout)
// if err != nil {
// log.Fatal(err)
// }
//
// for _, device := range devices {
// fmt.Printf("Found: %s at %s:%d\n", device.Name, device.Host, device.Port)
// }
//
// # Advanced Discovery
//
// Use specific discovery methods or configure advanced options:
//
// // Create a unified discovery service
// service, err := discovery.NewUnifiedDiscoveryService()
// if err != nil {
// log.Fatal(err)
// }
//
// // Discover with caching (devices cached for 5 minutes)
// devices, err := service.DiscoverWithCache(ctx, timeout)
// if err != nil {
// log.Fatal(err)
// }
//
// // Use only UPnP/SSDP discovery
// ssdpDevices, err := service.DiscoverUPnP(ctx, timeout)
// if err != nil {
// log.Fatal(err)
// }
//
// // Use only mDNS discovery
// mdnsDevices, err := service.DiscoverMDNS(ctx, timeout)
// if err != nil {
// log.Fatal(err)
// }
//
// # Discovery Methods
//
// The package supports two discovery protocols:
//
// - UPnP/SSDP: Discovers devices advertising UPnP services
// - mDNS/Bonjour: Discovers devices using multicast DNS
//
// The unified service automatically combines results from both methods and
// deduplicates devices found through multiple protocols.
//
// # Device Information
//
// Discovered devices contain comprehensive information:
//
// for _, device := range devices {
// fmt.Printf("Device: %s\n", device.Name)
// fmt.Printf("Host: %s:%d\n", device.Host, device.Port)
// fmt.Printf("MAC: %s\n", device.MACAddress)
// fmt.Printf("Method: %s\n", device.DiscoveryMethod)
// fmt.Printf("URL: %s\n", device.BaseURL)
// }
//
// # Caching
//
// The discovery service includes intelligent caching to avoid repeated network
// scans. Devices are cached for a configurable TTL (default: 5 minutes).
//
// # Error Handling
//
// Discovery operations may encounter various network conditions:
//
// devices, err := discovery.DiscoverDevices(ctx, timeout)
// if err != nil {
// // Handle discovery errors
// fmt.Printf("Discovery failed: %v\n", err)
// return
// }
//
// if len(devices) == 0 {
// fmt.Println("No SoundTouch devices found on the network")
// }
//
// # Configuration
//
// Discovery behavior can be customized through configuration:
//
// // Custom timeout for individual discovery methods
// service := &discovery.UnifiedDiscoveryService{
// CacheTTL: 10 * time.Minute, // Cache devices for 10 minutes
// }
package discovery
import (
+24 -7
View File
@@ -14,12 +14,17 @@ func TestBalanceMarshalXML(t *testing.T) {
}
var buf strings.Builder
encoder := xml.NewEncoder(&buf)
err := balance.MarshalXML(encoder, xml.StartElement{Name: xml.Name{Local: "balance"}})
if err != nil {
t.Fatalf("MarshalXML failed: %v", err)
}
encoder.Flush()
if err := encoder.Flush(); err != nil {
t.Fatalf("Flush failed: %v", err)
}
// Convert to string for easier testing
xmlStr := buf.String()
@@ -46,15 +51,19 @@ func TestBalanceMarshalXML_PositiveValue(t *testing.T) {
}
var buf strings.Builder
encoder := xml.NewEncoder(&buf)
err := balance.MarshalXML(encoder, xml.StartElement{Name: xml.Name{Local: "balance"}})
if err != nil {
t.Fatalf("MarshalXML failed: %v", err)
}
encoder.Flush()
if err := encoder.Flush(); err != nil {
t.Fatalf("Flush failed: %v", err)
}
xmlStr := buf.String()
expectedElements := []string{
`deviceID="ABCDEF123456"`,
`<targetbalance>30</targetbalance>`,
@@ -76,15 +85,19 @@ func TestBalanceMarshalXML_ZeroValue(t *testing.T) {
}
var buf strings.Builder
encoder := xml.NewEncoder(&buf)
err := balance.MarshalXML(encoder, xml.StartElement{Name: xml.Name{Local: "balance"}})
if err != nil {
t.Fatalf("MarshalXML failed: %v", err)
}
encoder.Flush()
if err := encoder.Flush(); err != nil {
t.Fatalf("Flush failed: %v", err)
}
xmlStr := buf.String()
expectedElements := []string{
`deviceID="ZERO0000TEST"`,
`<targetbalance>0</targetbalance>`,
@@ -106,15 +119,19 @@ func TestBalanceMarshalXML_ExtremeValues(t *testing.T) {
}
var buf strings.Builder
encoder := xml.NewEncoder(&buf)
err := balance.MarshalXML(encoder, xml.StartElement{Name: xml.Name{Local: "balance"}})
if err != nil {
t.Fatalf("MarshalXML failed: %v", err)
}
encoder.Flush()
if err := encoder.Flush(); err != nil {
t.Fatalf("Flush failed: %v", err)
}
xmlStr := buf.String()
expectedElements := []string{
`deviceID="EXTREME_TEST"`,
`<targetbalance>-50</targetbalance>`,
+6 -2
View File
@@ -222,14 +222,18 @@ func TestBassMarshalXML(t *testing.T) {
}
var buf strings.Builder
encoder := xml.NewEncoder(&buf)
err := bass.MarshalXML(encoder, xml.StartElement{Name: xml.Name{Local: "bass"}})
if err != nil {
t.Fatalf("MarshalXML failed: %v", err)
}
encoder.Flush()
// Convert to string for easier testing
if err := encoder.Flush(); err != nil {
t.Fatalf("Flush failed: %v", err)
}
xmlStr := buf.String()
// Check that XML contains expected elements
+119
View File
@@ -0,0 +1,119 @@
// Package models provides data structures for Bose SoundTouch Web API requests and responses.
//
// This package contains all the XML/JSON data models used to communicate with SoundTouch
// devices. These structures handle serialization and deserialization of API data,
// WebSocket events, and device state information.
//
// # Core Data Structures
//
// The package includes models for all major SoundTouch API endpoints:
//
// - DeviceInfo: Device information and capabilities
// - NowPlaying: Current playback status and track information
// - Volume: Volume levels and mute status
// - Bass: Bass control settings (-9 to +9)
// - Balance: Balance control settings (-50 to +50)
// - Sources: Available audio sources (Spotify, Bluetooth, etc.)
// - Presets: Configured preset buttons
// - Zone: Multiroom zone configuration
// - ClockTime/ClockDisplay: Device clock settings
// - NetworkInfo: Network connectivity information
//
// # Example Usage
//
// Working with device information:
//
// var info models.DeviceInfo
// err := xml.Unmarshal(responseData, &info)
// if err != nil {
// log.Fatal(err)
// }
// fmt.Printf("Device: %s (Type: %s)\n", info.Name, info.Type)
//
// Volume control:
//
// volume := models.Volume{
// ActualVolume: 50,
// TargetVolume: 50,
// Muted: false,
// }
//
// Creating zone configurations:
//
// zone := models.Zone{
// Master: "192.168.1.100",
// Members: []models.ZoneMember{
// {IPAddress: "192.168.1.101"},
// {IPAddress: "192.168.1.102"},
// },
// }
//
// # WebSocket Events
//
// The package includes models for real-time WebSocket events:
//
// - NowPlayingUpdated: Track changes and playback status
// - VolumeUpdated: Volume and mute state changes
// - ConnectionStateUpdated: Network connectivity changes
// - ZoneUpdated: Multiroom zone configuration changes
//
// Example WebSocket event handling:
//
// switch event := event.(type) {
// case *models.NowPlayingUpdated:
// fmt.Printf("Now playing: %s by %s\n", event.Track, event.Artist)
// case *models.VolumeUpdated:
// fmt.Printf("Volume: %d (Muted: %t)\n", event.ActualVolume, event.Muted)
// }
//
// # XML Serialization
//
// Most models support XML marshaling/unmarshaling for API communication:
//
// // Marshal to XML for API requests
// data, err := xml.Marshal(volume)
// if err != nil {
// log.Fatal(err)
// }
//
// // Unmarshal from XML responses
// var response models.DeviceInfo
// err = xml.Unmarshal(xmlData, &response)
// if err != nil {
// log.Fatal(err)
// }
//
// # Discovery Models
//
// Device discovery structures:
//
// device := models.DiscoveredDevice{
// Name: "Living Room",
// Host: "192.168.1.100",
// Port: 8090,
// SerialNo: "AA123456789",
// Location: "/device.xml",
// }
//
// # Validation and Constraints
//
// Many models include validation logic and constraints:
//
// - Volume: 0-100 range with mute support
// - Bass: -9 to +9 range
// - Balance: -50 (left) to +50 (right)
// - Keys: Predefined key constants (PLAY, PAUSE, etc.)
//
// # Thread Safety
//
// All model structures are safe for concurrent read access. For write access
// in concurrent environments, appropriate synchronization should be used.
//
// # Compatibility
//
// These models are compatible with all SoundTouch device types including:
// - SoundTouch 10, 20, 30 series
// - SoundTouch Portable
// - Wave SoundTouch music systems
// - Other SoundTouch-enabled Bose speakers
package models
+88 -73
View File
@@ -334,83 +334,98 @@ func ParseWebSocketEvent(data []byte) (*WebSocketEvent, error) {
return &event, nil
}
func (e *WebSocketEvent) getFieldByEventType(eventType WebSocketEventType) interface{} {
var field interface{}
switch eventType {
case EventTypeNowPlaying:
field = e.NowPlayingUpdated
case EventTypeVolumeUpdated:
field = e.VolumeUpdated
case EventTypeConnectionState:
field = e.ConnectionStateUpdated
case EventTypePresetUpdated:
field = e.PresetUpdated
case EventTypeZoneUpdated:
field = e.ZoneUpdated
case EventTypeBassUpdated:
field = e.BassUpdated
case EventTypeClockTimeUpdated:
field = e.ClockTimeUpdated
case EventTypeClockDisplayUpdated:
field = e.ClockDisplayUpdated
case EventTypeNameUpdated:
field = e.NameUpdated
case EventTypeErrorUpdated:
field = e.ErrorUpdated
case EventTypeRecentsUpdated:
field = e.RecentsUpdated
case EventTypeLanguageUpdated:
field = e.LanguageUpdated
}
// Use reflection or a type-safe check to ensure we only return non-nil interfaces
// In Go, an interface is nil only if both its type and value are nil.
// If e.NowPlayingUpdated is a nil pointer, field will be a non-nil interface containing a nil pointer.
// We need to return a literal nil if the field is empty to satisfy expectations.
if field == nil {
return nil
}
// We know all these fields are pointers.
// We can't easily check for nil pointer without reflection here in a generic way,
// but we can restore the previous logic in a more compact way if needed.
// Actually, the previous logic was: if e.NowPlayingUpdated != nil { return e.NowPlayingUpdated }
// which returns a non-nil interface.
return field
}
// isNil checks if an interface is nil or contains a nil pointer.
func isNil(i interface{}) bool {
if i == nil {
return true
}
switch v := i.(type) {
case *NowPlayingUpdatedEvent:
return v == nil
case *VolumeUpdatedEvent:
return v == nil
case *ConnectionStateUpdatedEvent:
return v == nil
case *PresetUpdatedEvent:
return v == nil
case *ZoneUpdatedEvent:
return v == nil
case *BassUpdatedEvent:
return v == nil
case *ClockTimeUpdatedEvent:
return v == nil
case *ClockDisplayUpdatedEvent:
return v == nil
case *NameUpdatedEvent:
return v == nil
case *ErrorUpdatedEvent:
return v == nil
case *RecentsUpdatedEvent:
return v == nil
case *LanguageUpdatedEvent:
return v == nil
}
return false
}
// ParseTypedEvent attempts to parse a WebSocket event into a specific typed event
func ParseTypedEvent[T any](event *WebSocketEvent, eventType WebSocketEventType) (T, error) {
var result T
// Get the event directly from the parsed structure
switch eventType {
case EventTypeNowPlaying:
if event.NowPlayingUpdated != nil {
if typedResult, ok := interface{}(event.NowPlayingUpdated).(T); ok {
return typedResult, nil
}
}
case EventTypeVolumeUpdated:
if event.VolumeUpdated != nil {
if typedResult, ok := interface{}(event.VolumeUpdated).(T); ok {
return typedResult, nil
}
}
case EventTypeConnectionState:
if event.ConnectionStateUpdated != nil {
if typedResult, ok := interface{}(event.ConnectionStateUpdated).(T); ok {
return typedResult, nil
}
}
case EventTypePresetUpdated:
if event.PresetUpdated != nil {
if typedResult, ok := interface{}(event.PresetUpdated).(T); ok {
return typedResult, nil
}
}
case EventTypeZoneUpdated:
if event.ZoneUpdated != nil {
if typedResult, ok := interface{}(event.ZoneUpdated).(T); ok {
return typedResult, nil
}
}
case EventTypeBassUpdated:
if event.BassUpdated != nil {
if typedResult, ok := interface{}(event.BassUpdated).(T); ok {
return typedResult, nil
}
}
case EventTypeClockTimeUpdated:
if event.ClockTimeUpdated != nil {
if typedResult, ok := interface{}(event.ClockTimeUpdated).(T); ok {
return typedResult, nil
}
}
case EventTypeClockDisplayUpdated:
if event.ClockDisplayUpdated != nil {
if typedResult, ok := interface{}(event.ClockDisplayUpdated).(T); ok {
return typedResult, nil
}
}
case EventTypeNameUpdated:
if event.NameUpdated != nil {
if typedResult, ok := interface{}(event.NameUpdated).(T); ok {
return typedResult, nil
}
}
case EventTypeErrorUpdated:
if event.ErrorUpdated != nil {
if typedResult, ok := interface{}(event.ErrorUpdated).(T); ok {
return typedResult, nil
}
}
case EventTypeRecentsUpdated:
if event.RecentsUpdated != nil {
if typedResult, ok := interface{}(event.RecentsUpdated).(T); ok {
return typedResult, nil
}
}
case EventTypeLanguageUpdated:
if event.LanguageUpdated != nil {
if typedResult, ok := interface{}(event.LanguageUpdated).(T); ok {
return typedResult, nil
}
field := event.getFieldByEventType(eventType)
if !isNil(field) {
if typedResult, ok := field.(T); ok {
return typedResult, nil
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ After successfully releasing v1.0.0, follow this checklist to maximize visibilit
Entry: [bose-soundtouch](https://github.com/gesellix/bose-soundtouch) - Go library for controlling Bose SoundTouch speakers with 100% API coverage and WebSocket events.
```
- [ ] Submit to **go-awesome**: https://github.com/shivammg/go-awesome
- [ ] Submit to **awesome-go**: https://github.com/avelino/awesome-go
- [ ] List on **awesome-home-assistant**: https://github.com/frenck/awesome-home-assistant
- [ ] Add to **IoT awesome lists**: Search for IoT/smart home Go libraries lists