mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-31 14:57:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca6ca3150a | ||
|
|
04d13c65d3 | ||
|
|
ce4ec02468 | ||
|
|
fc9decedd7 | ||
|
|
29cbcf48b9 | ||
|
|
2a9f219d40 | ||
|
|
546634572a | ||
|
|
56566a2b27 | ||
|
|
dee34c7b56 | ||
|
|
d6e998938a | ||
|
|
b80f8e958b | ||
|
|
7117ff6592 | ||
|
|
d7b1c94b9a | ||
|
|
3cc45ebd20 | ||
|
|
a30251854c | ||
|
|
1a1d37b885 | ||
|
|
666839dd4f | ||
|
|
468fdf8836 |
+121
-19
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user