Compare commits

...
15 Commits
Author SHA1 Message Date
Tobias Gesellchen c5a3911104 Fix SSDP discovery and enhance device discovery consistency
Major improvements to device discovery system:

🔧 **SSDP Discovery Fixed**:
- Fixed networking issue where SSDP used connected UDP socket instead of UDP listener
- SSDP now properly receives unicast responses from multicast requests
- UPnP discovery now works reliably and finds all MediaRenderer devices

 **Enhanced DiscoveredDevice Model**:
- Added consistent URL fields (APIBaseURL, InfoURL) for all discovery methods
- Added protocol-specific fields (UPnPLocation, UPnPUSN, MDNSHostname, etc.)
- Added DiscoveryMethod tracking to show how devices were found
- Added device merging support for same device found via multiple protocols

🚀 **Unified Discovery Improvements**:
- Fixed device merging logic to properly combine protocol-specific data
- Discovery methods now correctly show combinations like 'Configuration+SSDP/UPnP+mDNS/Bonjour'
- Removed duplicate configuration device loading in individual services
- All three discovery methods (SSDP, mDNS, Configuration) work together seamlessly

🛠 **Updated Tools & Examples**:
- Updated soundtouch-cli to display new consistent field structure
- Enhanced all example programs with better device information display
- Added new unified discovery example demonstrating all three methods
- Fixed context timeout issues in example programs

📋 **Comprehensive Testing**:
- All tests updated and passing
- Real-world validation with actual Bose SoundTouch devices
- Confirmed discovery methods properly merge device data

Every discovered device now has consistent http://host:port/info URLs regardless
of discovery method, while preserving valuable protocol-specific metadata.
2026-01-10 23:01:22 +01:00
Tobias Gesellchen ad43cdaf88 Fix mDNS discovery IPv6 issues and improve timeout handling
- Force IPv4-only mDNS queries with DisableIPv6=true to avoid routing issues
- Add automatic IPv4 interface selection for better compatibility
- Filter mDNS results to only include SoundTouch devices
- Clean up device names by unescaping mDNS characters
- Fix timeout flag handling to respect DISCOVERY_TIMEOUT from .env file
- Only override discovery timeout when --timeout flag is explicitly provided
- Add file operations safety guidelines to docs/CLAUDE.md
- Remove duplicate timeout flags from discover command, use global flags

Fixes IPv6 'no route to host' errors that prevented mDNS discovery.
Now discovers same devices as native dns-sd and dig tools.
2026-01-10 21:56:19 +01:00
Tobias Gesellchen fbc09fdc59 Add Contributor Covenant Code of Conduct
This document outlines the Contributor Covenant Code of Conduct, detailing our pledge, standards, enforcement responsibilities, and guidelines for community behavior.
2026-01-10 12:42:25 +01:00
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
36 changed files with 3066 additions and 602 deletions
+4
View File
@@ -10,6 +10,10 @@ on:
required: true
default: "v1.0.0"
permissions:
contents: write
actions: read
env:
GO_VERSION_FILE: "go.mod"
+128
View File
@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
tobias@gesellix.de.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
+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
+6 -1
View File
@@ -94,7 +94,12 @@ func main() {
fmt.Printf(" Host: %s\n", device.Host)
fmt.Printf(" Port: %d\n", device.Port)
fmt.Printf(" API URL: http://%s:%d/\n", device.Host, device.Port)
fmt.Printf(" Location: %s\n", device.Location)
fmt.Printf(" Info URL: %s\n", device.InfoURL)
if device.MDNSHostname != "" {
fmt.Printf(" mDNS Hostname: %s\n", device.MDNSHostname)
}
fmt.Printf(" Last seen: %s\n", device.LastSeen.Format("2006-01-02 15:04:05"))
fmt.Println()
}
+262
View File
@@ -0,0 +1,262 @@
// Package main provides an example of discovering SoundTouch devices using all three mechanisms.
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"time"
"github.com/gesellix/bose-soundtouch/pkg/config"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
)
func main() {
verbose := flag.Bool("verbose", false, "Enable verbose logging")
timeout := flag.Duration("timeout", 5*time.Second, "Discovery timeout")
showConfig := flag.Bool("show-config", false, "Show configuration details")
flag.Parse()
// Configure logging
if *verbose {
log.SetOutput(os.Stdout)
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
} else {
log.SetOutput(os.Stderr)
}
fmt.Println("SoundTouch Unified Discovery Example")
fmt.Println("===================================")
fmt.Printf("Timeout: %v, Verbose: %v\n", *timeout, *verbose)
fmt.Println()
// Load configuration
cfg, err := config.LoadFromEnv()
if err != nil {
fmt.Printf("Failed to load configuration: %v\n", err)
os.Exit(1)
}
// Override timeout from command line
cfg.DiscoveryTimeout = *timeout
if *showConfig {
printConfiguration(cfg)
}
fmt.Println("Testing individual discovery mechanisms:")
fmt.Println("--------------------------------------")
testSSDP(cfg, *timeout, *verbose)
testMDNS(cfg, *timeout, *verbose)
testConfig(cfg, *verbose)
testUnified(cfg, *timeout, *verbose)
}
func printConfiguration(cfg *config.Config) {
fmt.Println("Configuration:")
fmt.Printf(" UPnP Enabled: %v\n", cfg.UPnPEnabled)
fmt.Printf(" mDNS Enabled: %v\n", cfg.MDNSEnabled)
fmt.Printf(" Cache Enabled: %v\n", cfg.CacheEnabled)
fmt.Printf(" Discovery Timeout: %v\n", cfg.DiscoveryTimeout)
fmt.Printf(" Preferred Devices: %d\n", len(cfg.PreferredDevices))
for i, device := range cfg.PreferredDevices {
fmt.Printf(" %d. %s at %s:%d\n", i+1, device.Name, device.Host, device.Port)
}
fmt.Println()
}
func testSSDP(cfg *config.Config, timeout time.Duration, verbose bool) {
// Test SSDP discovery
fmt.Println("1. SSDP/UPnP Discovery:")
if cfg.UPnPEnabled {
// Create fresh context for SSDP test
ssdpCtx, ssdpCancel := context.WithTimeout(context.Background(), timeout+2*time.Second)
defer ssdpCancel()
ssdpService := discovery.NewServiceWithConfig(cfg)
start := time.Now()
ssdpDevices, ssdpErr := ssdpService.DiscoverDevices(ssdpCtx)
duration := time.Since(start)
if ssdpErr != nil {
fmt.Printf(" Error: %v\n", ssdpErr)
} else {
fmt.Printf(" Found %d devices in %v\n", len(ssdpDevices), duration)
for _, device := range ssdpDevices {
fmt.Printf(" - %s (%s:%d)\n", device.Name, device.Host, device.Port)
if verbose {
fmt.Printf(" Info URL: %s\n", device.InfoURL)
if device.UPnPLocation != "" {
fmt.Printf(" UPnP Location: %s\n", device.UPnPLocation)
}
}
}
}
} else {
fmt.Println(" Disabled in configuration")
}
fmt.Println()
}
func testMDNS(cfg *config.Config, timeout time.Duration, verbose bool) {
// Test mDNS discovery
fmt.Println("2. mDNS/Bonjour Discovery:")
if cfg.MDNSEnabled {
// Create fresh context for mDNS test
mdnsCtx, mdnsCancel := context.WithTimeout(context.Background(), timeout+2*time.Second)
defer mdnsCancel()
mdnsService := discovery.NewMDNSDiscoveryService(timeout)
start := time.Now()
mdnsDevices, mdnsErr := mdnsService.DiscoverDevices(mdnsCtx)
duration := time.Since(start)
if mdnsErr != nil {
fmt.Printf(" Error: %v\n", mdnsErr)
} else {
fmt.Printf(" Found %d devices in %v\n", len(mdnsDevices), duration)
for _, device := range mdnsDevices {
fmt.Printf(" - %s (%s:%d)\n", device.Name, device.Host, device.Port)
if verbose {
fmt.Printf(" Info URL: %s\n", device.InfoURL)
if device.MDNSHostname != "" {
fmt.Printf(" mDNS Hostname: %s\n", device.MDNSHostname)
}
}
}
}
} else {
fmt.Println(" Disabled in configuration")
}
fmt.Println()
}
func testConfig(cfg *config.Config, verbose bool) {
// Test configuration-based devices
fmt.Println("3. Configuration-based Devices:")
configDevices := cfg.GetPreferredDevicesAsDiscovered()
if len(configDevices) > 0 {
fmt.Printf(" Found %d configured devices\n", len(configDevices))
for _, device := range configDevices {
fmt.Printf(" - %s (%s:%d)\n", device.Name, device.Host, device.Port)
if verbose {
fmt.Printf(" Info URL: %s\n", device.InfoURL)
}
}
} else {
fmt.Println(" No devices configured in .env file")
}
fmt.Println()
}
func testUnified(cfg *config.Config, timeout time.Duration, verbose bool) {
// Test unified discovery
fmt.Println("4. Unified Discovery (combines all methods):")
// Create fresh context for unified test
unifiedCtx, unifiedCancel := context.WithTimeout(context.Background(), timeout+2*time.Second)
defer unifiedCancel()
unifiedService := discovery.NewUnifiedDiscoveryService(cfg)
start := time.Now()
allDevices, err := unifiedService.DiscoverDevices(unifiedCtx)
duration := time.Since(start)
if err != nil {
fmt.Printf(" Error: %v\n", err)
return
}
fmt.Printf(" Found %d total devices in %v\n", len(allDevices), duration)
fmt.Println()
if len(allDevices) == 0 {
fmt.Println("No SoundTouch devices found via any discovery method")
fmt.Println()
fmt.Println("This could mean:")
fmt.Println("- No SoundTouch devices on network")
fmt.Println("- All discovery methods are disabled")
fmt.Println("- Network blocks multicast traffic")
fmt.Println("- Devices are not advertising services")
return
}
fmt.Println("Unified Device List:")
fmt.Println("-------------------")
for i, device := range allDevices {
fmt.Printf("%d. %s\n", i+1, device.Name)
fmt.Printf(" Host: %s\n", device.Host)
fmt.Printf(" Port: %d\n", device.Port)
fmt.Printf(" API Base URL: %s\n", device.APIBaseURL)
fmt.Printf(" Info URL: %s\n", device.InfoURL)
fmt.Printf(" Discovery Method: %s\n", device.DiscoveryMethod)
fmt.Printf(" Last seen: %s\n", device.LastSeen.Format("2006-01-02 15:04:05"))
if verbose {
if device.ModelID != "" {
fmt.Printf(" Model ID: %s\n", device.ModelID)
}
if device.SerialNo != "" {
fmt.Printf(" Serial No: %s\n", device.SerialNo)
}
// Show protocol-specific details
if device.UPnPLocation != "" {
fmt.Printf(" UPnP Location: %s\n", device.UPnPLocation)
if device.UPnPUSN != "" {
fmt.Printf(" UPnP USN: %s\n", device.UPnPUSN)
}
}
if device.MDNSHostname != "" {
fmt.Printf(" mDNS Hostname: %s\n", device.MDNSHostname)
if device.MDNSService != "" {
fmt.Printf(" mDNS Service: %s\n", device.MDNSService)
}
}
if device.ConfigName != "" {
fmt.Printf(" Config Name: %s\n", device.ConfigName)
}
}
fmt.Println()
}
fmt.Printf("✓ Unified discovery completed successfully!\n")
fmt.Printf("✓ Found %d unique device(s) in %v\n", len(allDevices), duration)
if verbose {
fmt.Println()
fmt.Println("Technical Details:")
fmt.Printf("- SSDP multicast address: 239.255.255.250:1900\n")
fmt.Printf("- mDNS service type: _soundtouch._tcp.local\n")
fmt.Printf("- Discovery timeout: %v\n", timeout)
fmt.Printf("- Configuration file: .env (if present)\n")
}
}
+6 -1
View File
@@ -103,7 +103,12 @@ func main() {
fmt.Printf(" Host: %s\n", device.Host)
fmt.Printf(" Port: %d\n", device.Port)
fmt.Printf(" API URL: http://%s:%d/\n", device.Host, device.Port)
fmt.Printf(" Location: %s\n", device.Location)
fmt.Printf(" Info URL: %s\n", device.InfoURL)
if device.UPnPLocation != "" {
fmt.Printf(" UPnP Location: %s\n", device.UPnPLocation)
}
fmt.Printf(" Last seen: %s\n", device.LastSeen.Format("2006-01-02 15:04:05"))
fmt.Println()
}
+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 {
+80 -25
View File
@@ -7,34 +7,29 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/config"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/urfave/cli/v2"
)
// discoverDevices handles device discovery command
func discoverDevices(c *cli.Context) error {
timeout := c.Duration("timeout")
showAll := c.Bool("all")
fmt.Printf("Discovering SoundTouch devices...\n")
if showAll {
fmt.Printf("Timeout: %v\n", timeout)
fmt.Printf("Mode: Detailed information\n")
}
fmt.Println()
// Load configuration
cfg, err := config.LoadFromEnv()
if err != nil {
cfg = config.DefaultConfig()
}
// Override discovery timeout if provided
if timeout > 0 {
cfg.DiscoveryTimeout = timeout
// Update config with CLI flags
updateConfigFromCLI(c, cfg)
if c.Bool("all") {
printDiscoveryContext(cfg)
}
fmt.Println()
// Create discovery service
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
@@ -48,18 +43,51 @@ func discoverDevices(c *cli.Context) error {
}
if len(devices) == 0 {
fmt.Println("No SoundTouch devices found on the network.")
fmt.Println()
fmt.Println("This could mean:")
fmt.Println("- No SoundTouch devices are powered on")
fmt.Println("- Devices are on a different network segment")
fmt.Println("- Network blocks multicast traffic")
fmt.Println("- Firewall is blocking discovery ports")
printNoDevicesMessage()
return nil
}
// Display results
printDiscoveryResults(devices, c.Bool("all"))
return nil
}
func updateConfigFromCLI(c *cli.Context, cfg *config.Config) {
if c.IsSet("timeout") {
httpTimeout := c.Duration("timeout")
cfg.HTTPTimeout = httpTimeout
// Set discovery timeout to be 2x HTTP timeout (min 5s, max 30s)
discoveryTimeout := httpTimeout * 2
if discoveryTimeout < 5*time.Second {
discoveryTimeout = 5 * time.Second
}
if discoveryTimeout > 30*time.Second {
discoveryTimeout = 30 * time.Second
}
cfg.DiscoveryTimeout = discoveryTimeout
}
}
func printDiscoveryContext(cfg *config.Config) {
fmt.Printf("HTTP Timeout: %v\n", cfg.HTTPTimeout)
fmt.Printf("Discovery Timeout: %v\n", cfg.DiscoveryTimeout)
fmt.Printf("Mode: Detailed information\n")
}
func printNoDevicesMessage() {
fmt.Println("No SoundTouch devices found on the network.")
fmt.Println()
fmt.Println("This could mean:")
fmt.Println("- No SoundTouch devices are powered on")
fmt.Println("- Devices are on a different network segment")
fmt.Println("- Network blocks multicast traffic")
fmt.Println("- Firewall is blocking discovery ports")
}
func printDiscoveryResults(devices []*models.DiscoveredDevice, showAll bool) {
fmt.Printf("Found %d SoundTouch device(s):\n\n", len(devices))
for i, device := range devices {
@@ -71,11 +99,40 @@ func discoverDevices(c *cli.Context) error {
fmt.Printf(" Serial: %s\n", device.SerialNo)
}
if device.Location != "" {
fmt.Printf(" Location: %s\n", device.Location)
if device.APIBaseURL != "" {
fmt.Printf(" API Base URL: %s\n", device.APIBaseURL)
}
if device.InfoURL != "" {
fmt.Printf(" Info URL: %s\n", device.InfoURL)
}
if device.DiscoveryMethod != "" {
fmt.Printf(" Discovery Method: %s\n", device.DiscoveryMethod)
}
if showAll {
// Show protocol-specific details in verbose mode
if device.UPnPLocation != "" {
fmt.Printf(" UPnP Location: %s\n", device.UPnPLocation)
}
if device.UPnPUSN != "" {
fmt.Printf(" UPnP USN: %s\n", device.UPnPUSN)
}
if device.MDNSHostname != "" {
fmt.Printf(" mDNS Hostname: %s\n", device.MDNSHostname)
}
if device.MDNSService != "" {
fmt.Printf(" mDNS Service: %s\n", device.MDNSService)
}
if device.ConfigName != "" {
fmt.Printf(" Config Name: %s\n", device.ConfigName)
}
fmt.Printf(" Last Seen: %s\n", device.LastSeen.Format("2006-01-02 15:04:05"))
}
@@ -88,6 +145,4 @@ func discoverDevices(c *cli.Context) error {
fmt.Println()
fmt.Printf("Use any of these hosts with other commands:\n")
fmt.Printf("Example: soundtouch-cli info --host %s\n", devices[0].Host)
return nil
}
+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 -2
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",
@@ -54,7 +68,6 @@ func main() {
{
Name: "name",
Usage: "Get or set device name",
Flags: CommonFlags,
Before: RequireHost,
Subcommands: []*cli.Command{
{
+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
+8
View File
@@ -80,6 +80,14 @@ When creating test data for API endpoints, prefer real device responses over hyp
- **Coverage**: Use multiple real devices to cover different response variations
- **Non-responsive endpoints**: Some endpoints like `/trackInfo` may not respond or exist on all devices
### 9. File Operations Safety
- **Never delete files** - use move/rename instead when possible
- **Ask before destructive operations** - especially for config files (.env, *.config, etc.)
- **Prefer non-destructive operations** - copy, move, rename over delete
- **Respect user data** - treat all user files as potentially containing sensitive data
- **Configuration files are sacred** - .env, config files may contain secrets and personal settings
## Additional Notes
- **Language: English** for code, commits, labels, and text in code
+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
+566
View File
@@ -0,0 +1,566 @@
# Manual Network Discovery on macOS
This document provides comprehensive guidance for manually discovering network services and devices using built-in macOS tools and command-line utilities. This is particularly useful for troubleshooting network discovery issues or understanding what services are available on your local network.
## Overview
Network service discovery typically relies on two main protocols:
- **mDNS (Multicast DNS)** - Used by Apple devices, printers, and many local services
- **SSDP (Simple Service Discovery Protocol)** - Used by UPnP devices, media servers, and smart home devices
## mDNS (Multicast DNS) Discovery
**Multicast Address:** `224.0.0.251:5353`
mDNS is the underlying protocol for Bonjour/Zeroconf services. It allows devices to advertise services on the local network using `.local` domain names.
### Built-in Tools (Recommended)
macOS includes `dns-sd`, a powerful command-line tool for service discovery:
```bash
# Browse for all available service types
dns-sd -B _services._dns-sd._udp local.
# Browse for specific service types
dns-sd -B _http._tcp local. # Web servers
dns-sd -B _airplay._tcp local. # AirPlay devices
dns-sd -B _ipp._tcp local. # Internet Printing Protocol
dns-sd -B _soundtouch._tcp local. # Bose SoundTouch devices
dns-sd -B _ssh._tcp local. # SSH servers
dns-sd -B _afpovertcp._tcp local. # AFP file sharing
# Resolve a specific service to get IP address and port
dns-sd -L "ServiceName" _http._tcp local.
# Register a test service (useful for testing)
dns-sd -R "TestService" _http._tcp local 8080
# Query for a specific record type
dns-sd -Q hostname.local A # Get IPv4 address
dns-sd -Q hostname.local AAAA # Get IPv6 address
```
### Using dig Command
The `dig` command can also query mDNS directly:
```bash
# Query for a specific hostname
dig @224.0.0.251 -p 5353 hostname.local
# Query for all service types
dig @224.0.0.251 -p 5353 _services._dns-sd._udp.local PTR
# Query for specific service instances
dig @224.0.0.251 -p 5353 _http._tcp.local PTR
# Get detailed information with additional records
dig @224.0.0.251 -p 5353 _soundtouch._tcp.local PTR +additional
```
### Advanced mDNS Monitoring
```bash
# Monitor all mDNS traffic (requires sudo)
sudo tcpdump -i any -n -s 0 'port 5353'
# Monitor specific service announcements
sudo tcpdump -i any -n -s 0 -A 'port 5353 and host 224.0.0.251'
# Monitor with human-readable timestamps
sudo tcpdump -i any -n -s 0 -t -A 'port 5353'
```
### With Homebrew (Optional)
For additional tools, you can install Avahi:
```bash
brew install avahi
# Browse all services
avahi-browse -a
# Browse with verbose details
avahi-browse -a -v -t
# Browse only for a limited time
avahi-browse -a -t --timeout=10
# Resolve a specific service
avahi-resolve -n hostname.local
# Publish a test service
avahi-publish -s "Test Service" _http._tcp 8080
```
## SSDP (Simple Service Discovery Protocol)
**Multicast Address:** `239.255.255.250:1900`
SSDP is used by UPnP devices to advertise and discover services. It uses HTTP-like messages over UDP multicast.
### Active Discovery (M-SEARCH)
This method sends out discovery requests and waits for responses:
**Terminal 1 - Capture responses:**
```bash
# Monitor all SSDP traffic
sudo tcpdump -i any -n -A 'udp port 1900'
# Monitor with better formatting
sudo tcpdump -i any -n -s 0 -A 'udp port 1900' | grep -E '(M-SEARCH|HTTP|NOTIFY|ST:|USN:|LOCATION:)'
```
**Terminal 2 - Send discovery requests:**
```bash
# Basic discovery for all devices
echo -e "M-SEARCH * HTTP/1.1\r\nHost:239.255.255.250:1900\r\nST:ssdp:all\r\nMan:\"ssdp:discover\"\r\nMX:3\r\n\r\n" | nc -u 239.255.255.250 1900
# Search for specific device types
echo -e "M-SEARCH * HTTP/1.1\r\nHost:239.255.255.250:1900\r\nST:urn:schemas-upnp-org:device:MediaRenderer:1\r\nMan:\"ssdp:discover\"\r\nMX:3\r\n\r\n" | nc -u 239.255.255.250 1900
# Search for root devices only
echo -e "M-SEARCH * HTTP/1.1\r\nHost:239.255.255.250:1900\r\nST:upnp:rootdevice\r\nMan:\"ssdp:discover\"\r\nMX:3\r\n\r\n" | nc -u 239.255.255.250 1900
# Search with longer timeout for slow devices
echo -e "M-SEARCH * HTTP/1.1\r\nHost:239.255.255.250:1900\r\nST:ssdp:all\r\nMan:\"ssdp:discover\"\r\nMX:10\r\n\r\n" | nc -u 239.255.255.250 1900
```
### Passive Listening (NOTIFY messages)
Devices periodically send NOTIFY messages to announce their presence:
```bash
# Simple listening (may miss some messages)
nc -ul 1900
# More reliable listening with proper multicast join
# First, install socat if not available
brew install socat
# Listen to multicast SSDP traffic
socat - UDP4-RECVFROM:1900,ip-add-membership=239.255.255.250:0.0.0.0,fork
# Alternative: bind to specific interface
socat - UDP4-RECVFROM:1900,ip-add-membership=239.255.255.250:en0,fork
```
### Python Script for SSDP Discovery
For more reliable and detailed discovery, use this Python script:
```python
#!/usr/bin/env python3
"""
SSDP Discovery Script
Sends M-SEARCH requests and collects responses from UPnP devices.
"""
import socket
import time
import re
from urllib.parse import urlparse
# M-SEARCH message for discovering all SSDP devices
MSEARCH_MSG = \
'M-SEARCH * HTTP/1.1\r\n' \
'HOST:239.255.255.250:1900\r\n' \
'ST:ssdp:all\r\n' \
'MX:3\r\n' \
'MAN:"ssdp:discover"\r\n' \
'\r\n'
def discover_devices(timeout=5, retries=2):
"""Discover UPnP devices using SSDP."""
devices = {}
for attempt in range(retries):
print(f"\n--- Discovery attempt {attempt + 1} ---")
# Create UDP socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.settimeout(timeout)
try:
# Send M-SEARCH request
sock.sendto(MSEARCH_MSG.encode(), ('239.255.255.250', 1900))
# Collect responses
start_time = time.time()
while time.time() - start_time < timeout:
try:
data, addr = sock.recvfrom(8192)
response = data.decode('utf-8', errors='ignore')
# Parse the response
device_info = parse_ssdp_response(response, addr)
if device_info:
# Use USN as unique identifier
usn = device_info.get('USN', f"{addr[0]}:unknown")
devices[usn] = device_info
except socket.timeout:
continue
except Exception as e:
print(f"Error receiving data: {e}")
continue
except Exception as e:
print(f"Discovery attempt {attempt + 1} failed: {e}")
finally:
sock.close()
return devices
def parse_ssdp_response(response, addr):
"""Parse SSDP response and extract device information."""
lines = response.split('\r\n')
# Check if it's a valid HTTP response
if not lines[0].startswith('HTTP/1.1 200 OK'):
return None
device_info = {
'IP': addr[0],
'Port': addr[1],
'Raw': response
}
# Parse headers
for line in lines[1:]:
if ':' in line:
key, value = line.split(':', 1)
device_info[key.strip().upper()] = value.strip()
return device_info
def print_device_summary(devices):
"""Print a summary of discovered devices."""
if not devices:
print("\nNo devices discovered.")
return
print(f"\n--- Discovered {len(devices)} devices ---")
for usn, device in devices.items():
print(f"\nDevice: {device.get('SERVER', 'Unknown')}")
print(f" IP: {device['IP']}")
print(f" USN: {device.get('USN', 'N/A')}")
print(f" ST: {device.get('ST', 'N/A')}")
location = device.get('LOCATION')
if location:
parsed = urlparse(location)
print(f" Location: {location}")
print(f" Host: {parsed.hostname}:{parsed.port}")
def print_detailed_info(devices):
"""Print detailed information for all devices."""
for i, (usn, device) in enumerate(devices.items(), 1):
print(f"\n{'='*60}")
print(f"Device {i}: {device['IP']}")
print(f"{'='*60}")
print(device['Raw'])
if __name__ == "__main__":
print("SSDP Device Discovery")
print("Searching for UPnP devices on the network...")
# Discover devices
devices = discover_devices(timeout=5, retries=2)
# Print results
print_device_summary(devices)
# Ask if user wants detailed info
if devices:
response = input("\nShow detailed device information? (y/N): ")
if response.lower() == 'y':
print_detailed_info(devices)
```
Save this script and run it:
```bash
# Save the script
cat > ssdp_discovery.py << 'EOF'
# [paste the Python script above]
EOF
# Make it executable
chmod +x ssdp_discovery.py
# Run the discovery
python3 ssdp_discovery.py
```
### SSDP Message Types
Understanding SSDP message types helps interpret the traffic:
**M-SEARCH Request:**
```
M-SEARCH * HTTP/1.1
HOST:239.255.255.250:1900
ST:ssdp:all
MAN:"ssdp:discover"
MX:3
```
**NOTIFY Advertisement:**
```
NOTIFY * HTTP/1.1
HOST:239.255.255.250:1900
CACHE-CONTROL:max-age=1800
LOCATION:http://192.168.1.100:8090/device_description.xml
NT:upnp:rootdevice
NTS:ssdp:alive
USN:uuid:12345678-1234-1234-1234-123456789012::upnp:rootdevice
```
**HTTP Response:**
```
HTTP/1.1 200 OK
CACHE-CONTROL:max-age=1800
DATE:Wed, 18 Dec 2024 10:30:00 GMT
EXT:
LOCATION:http://192.168.1.100:8090/device_description.xml
SERVER:Linux/3.0 UPnP/1.0 Device/1.0
ST:upnp:rootdevice
USN:uuid:12345678-1234-1234-1234-123456789012::upnp:rootdevice
```
## Network Interface Discovery
### Find Your Network Interfaces
```bash
# List all network interfaces
ifconfig
# Show only active interfaces with IP addresses
ifconfig | grep -A 1 "inet "
# Show routing table to find default interface
netstat -rn | grep default
# Use route command (alternative)
route get default
```
### Find Your Network Segment
```bash
# Get your IP and netmask
ifconfig en0 | grep inet
# Show ARP table (devices that have communicated recently)
arp -a
# Scan local network segment (requires nmap)
brew install nmap
nmap -sn 192.168.1.0/24 # Adjust network range as needed
# Quick ping sweep (built-in)
for i in {1..254}; do ping -c 1 -t 1 192.168.1.$i >/dev/null 2>&1 && echo "192.168.1.$i is up"; done
```
## Troubleshooting Discovery Issues
### Common Problems and Solutions
**1. No responses to mDNS queries:**
```bash
# Check if mDNS daemon is running
sudo launchctl list | grep mDNSResponder
# Restart mDNS if needed (rarely required)
sudo launchctl kickstart -k system/com.apple.mDNSResponder
# Test basic mDNS functionality
dns-sd -B _services._dns-sd._udp local.
```
**2. No responses to SSDP queries:**
```bash
# Check if firewall is blocking multicast
sudo pfctl -sr | grep 1900
# Test multicast connectivity
ping 239.255.255.250
# Check interface supports multicast
ifconfig en0 | grep MULTICAST
```
**3. Network interface issues:**
```bash
# Check which interface is being used
route get 239.255.255.250
# Force specific interface for testing
ping -I en0 239.255.255.250
sudo tcpdump -i en0 'port 5353 or port 1900'
```
**4. Firewall blocking discovery:**
```bash
# Check macOS firewall status
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate
# Temporarily disable firewall for testing (BE CAREFUL)
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate off
# Re-enable firewall after testing
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on
```
### Debugging Tools
**Monitor all discovery traffic:**
```bash
# Watch both mDNS and SSDP traffic
sudo tcpdump -i any -n -s 0 'port 5353 or port 1900'
# Save traffic to file for analysis
sudo tcpdump -i any -n -s 0 -w discovery.pcap 'port 5353 or port 1900'
# Analyze with specific filters
sudo tcpdump -i any -n -A 'port 5353' | grep -i soundtouch
```
**Network connectivity tests:**
```bash
# Test multicast group membership
netstat -g
# Test UDP connectivity
nc -u 192.168.1.100 8090 # Replace with actual device IP
# Test HTTP connectivity to discovered devices
curl -i http://192.168.1.100:8090/info # SoundTouch info endpoint
```
## Protocol Comparison
| Protocol | Port | Multicast Address | Use Case | Discovery Method |
|----------|------|------------------|----------|------------------|
| **mDNS** | 5353 | 224.0.0.251 | Apple devices, printers, local services | Query `.local` names, browse service types |
| **SSDP** | 1900 | 239.255.255.250 | UPnP devices, media servers, smart home | M-SEARCH requests, NOTIFY advertisements |
## Advanced Techniques
### Continuous Monitoring
Create a script to continuously monitor for new devices:
```bash
#!/bin/bash
# continuous_discovery.sh
echo "Starting continuous network discovery monitoring..."
echo "Press Ctrl+C to stop"
# Function to handle cleanup
cleanup() {
echo -e "\nStopping monitoring..."
kill $TCPDUMP_PID 2>/dev/null
kill $MDNS_PID 2>/dev/null
exit 0
}
trap cleanup INT TERM
# Start background monitoring
sudo tcpdump -i any -n -l 'port 5353 or port 1900' &
TCPDUMP_PID=$!
# Periodic active discovery
while true; do
echo -e "\n--- $(date) - Active Discovery Sweep ---"
# mDNS discovery
timeout 5 dns-sd -B _services._dns-sd._udp local. &
MDNS_PID=$!
# SSDP discovery
echo -e "M-SEARCH * HTTP/1.1\r\nHost:239.255.255.250:1900\r\nST:ssdp:all\r\nMan:\"ssdp:discover\"\r\nMX:3\r\n\r\n" | nc -u 239.255.255.250 1900
# Wait before next sweep
sleep 30
done
```
### Device-Specific Queries
For SoundTouch devices specifically:
```bash
# Look for SoundTouch-specific services
dns-sd -B _soundtouch._tcp local.
# Query for SoundTouch device descriptions
dns-sd -L "Bose SoundTouch" _soundtouch._tcp local.
# SSDP query for media renderers (SoundTouch devices often respond)
echo -e "M-SEARCH * HTTP/1.1\r\nHost:239.255.255.250:1900\r\nST:urn:schemas-upnp-org:device:MediaRenderer:1\r\nMan:\"ssdp:discover\"\r\nMX:5\r\n\r\n" | nc -u 239.255.255.250 1900
```
### Creating Test Services
For testing your discovery setup:
```bash
# Register a test mDNS service
dns-sd -R "TestDevice" _http._tcp local 8080 &
TEST_PID=$!
# Test that it can be discovered
dns-sd -B _http._tcp local.
# Clean up
kill $TEST_PID
```
## Security Considerations
- **Network exposure**: Discovery protocols broadcast device information
- **No authentication**: Discovery traffic is typically unauthenticated
- **Information disclosure**: Device details may be visible to entire network
- **Firewall configuration**: Consider allowing only necessary multicast traffic
## Quick Reference
### Essential Commands
```bash
# Quick mDNS service browse
dns-sd -B _services._dns-sd._udp local.
# Quick SSDP discovery
echo -e "M-SEARCH * HTTP/1.1\r\nHost:239.255.255.250:1900\r\nST:ssdp:all\r\nMan:\"ssdp:discover\"\r\nMX:3\r\n\r\n" | nc -u 239.255.255.250 1900
# Monitor all discovery traffic
sudo tcpdump -i any -n 'port 5353 or port 1900'
# Test specific device connectivity
curl -i http://device-ip:8090/info
```
### Common Service Types
| Service Type | Protocol | Description |
|-------------|----------|-------------|
| `_http._tcp` | mDNS | Web servers |
| `_airplay._tcp` | mDNS | AirPlay devices |
| `_soundtouch._tcp` | mDNS | Bose SoundTouch |
| `_ipp._tcp` | mDNS | Printers |
| `_ssh._tcp` | mDNS | SSH servers |
| `upnp:rootdevice` | SSDP | UPnP root devices |
| `urn:schemas-upnp-org:device:MediaRenderer:1` | SSDP | Media players |
This guide provides comprehensive tools for manually discovering and troubleshooting network services on macOS. Use these techniques to understand what devices and services are available on your network, debug discovery issues, and verify that your applications are correctly implementing discovery protocols.
+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
}
}
+8 -5
View File
@@ -112,11 +112,14 @@ func (c *Config) GetPreferredDevicesAsDiscovered() []*models.DiscoveredDevice {
for _, device := range c.PreferredDevices {
discovered := &models.DiscoveredDevice{
Name: device.Name,
Host: device.Host,
Port: device.Port,
Location: fmt.Sprintf("http://%s:%d/info", device.Host, device.Port),
LastSeen: time.Now(),
Name: device.Name,
Host: device.Host,
Port: device.Port,
LastSeen: time.Now(),
DiscoveryMethod: "Configuration",
APIBaseURL: fmt.Sprintf("http://%s:%d/", device.Host, device.Port),
InfoURL: fmt.Sprintf("http://%s:%d/info", device.Host, device.Port),
ConfigName: device.Name,
}
devices = append(devices, discovered)
}
+3 -3
View File
@@ -348,9 +348,9 @@ func TestGetPreferredDevicesAsDiscovered(t *testing.T) {
t.Errorf("Expected port 8090, got %d", devices[0].Port)
}
expectedLocation := "http://192.168.1.100:8090/info"
if devices[0].Location != expectedLocation {
t.Errorf("Expected location '%s', got '%s'", expectedLocation, devices[0].Location)
expectedInfoURL := "http://192.168.1.100:8090/info"
if devices[0].InfoURL != expectedInfoURL {
t.Errorf("Expected info URL '%s', got '%s'", expectedInfoURL, devices[0].InfoURL)
}
}
+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(" Info URL: %s\n", device.InfoURL)
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
}
+92 -13
View File
@@ -47,18 +47,38 @@ func (m *MDNSDiscoveryService) DiscoverDevices(ctx context.Context) ([]*models.D
log.Printf("mDNS: Starting discovery for service '%s.%s' with timeout %v",
soundTouchServiceType, soundTouchDomain, m.timeout)
// Query for SoundTouch devices
// Note: hashicorp/mdns expects service and domain separately
// IPv4-only query to fix "no route to host" errors on IPv6
// This addresses the issue where hashicorp/mdns fails with:
// "write udp6 [::]:port->[ff02::fb]:5353: sendto: no route to host"
// The trailing dot in service names is handled correctly by separating
// service and domain parameters as expected by the library.
err := mdns.Query(&mdns.QueryParam{
Service: "_soundtouch._tcp",
Domain: "local.",
Timeout: m.timeout,
Entries: entries,
Service: "_soundtouch._tcp",
Domain: "local.",
Timeout: m.timeout,
Entries: entries,
DisableIPv6: true, // Force IPv4 only to avoid routing issues
Interface: m.getIPv4Interface(), // Use specific interface if available
})
if err != nil {
log.Printf("mDNS query completed with error: %v", err)
log.Printf("mDNS IPv4 query failed: %v", err)
// Fallback to standard query (both IPv4 and IPv6)
log.Printf("mDNS: Falling back to standard query...")
err = mdns.Query(&mdns.QueryParam{
Service: "_soundtouch._tcp",
Domain: "local.",
Timeout: m.timeout,
Entries: entries,
})
if err != nil {
log.Printf("mDNS query completed with error: %v", err)
} else {
log.Printf("mDNS query completed successfully")
}
} else {
log.Printf("mDNS query completed successfully")
log.Printf("mDNS IPv4 query completed successfully")
}
}()
@@ -78,6 +98,12 @@ func (m *MDNSDiscoveryService) DiscoverDevices(ctx context.Context) ([]*models.D
log.Printf("mDNS: Received service entry: Name='%s', Host='%s', Port=%d, AddrV4=%v, AddrV6=%v",
entry.Name, entry.Host, entry.Port, entry.AddrV4, entry.AddrV6)
// Only process SoundTouch devices
if !strings.Contains(entry.Name, "_soundtouch._tcp") {
log.Printf("mDNS: Skipping non-SoundTouch service: %s", entry.Name)
continue
}
device := m.serviceEntryToDevice(entry)
if device != nil {
log.Printf("mDNS: Successfully converted to device: %s at %s:%d", device.Name, device.Host, device.Port)
@@ -170,15 +196,68 @@ func (m *MDNSDiscoveryService) serviceEntryToDevice(entry *mdns.ServiceEntry) *m
name = strings.TrimSuffix(name, "."+soundTouchServiceType+"."+soundTouchDomain)
}
// Unescape any escaped characters in the name (common in mDNS)
name = strings.ReplaceAll(name, `\ `, " ")
name = strings.ReplaceAll(name, `\.`, ".")
name = strings.ReplaceAll(name, `\\`, `\`)
device := &models.DiscoveredDevice{
Host: host,
Port: port,
Name: name,
Location: fmt.Sprintf("http://%s:%d/info", host, port),
LastSeen: time.Now(),
Host: host,
Port: port,
Name: name,
LastSeen: time.Now(),
DiscoveryMethod: "mDNS/Bonjour",
APIBaseURL: fmt.Sprintf("http://%s:%d/", host, port),
InfoURL: fmt.Sprintf("http://%s:%d/info", host, port),
MDNSHostname: entry.Host,
MDNSService: entry.Name,
}
log.Printf("mDNS: Created device '%s' at %s:%d (IP source: %s)", name, host, port, ipSource)
return device
}
// getIPv4Interface returns the first suitable IPv4 network interface
func (m *MDNSDiscoveryService) getIPv4Interface() *net.Interface {
interfaces, err := net.Interfaces()
if err != nil {
log.Printf("mDNS: Failed to get network interfaces: %v", err)
return nil
}
for _, iface := range interfaces {
// Skip loopback, down interfaces, and point-to-point interfaces
if iface.Flags&net.FlagLoopback != 0 ||
iface.Flags&net.FlagUp == 0 ||
iface.Flags&net.FlagPointToPoint != 0 {
continue
}
// Check if this interface has IPv4 addresses
addrs, err := iface.Addrs()
if err != nil {
continue
}
hasIPv4 := false
for _, addr := range addrs {
if ipNet, ok := addr.(*net.IPNet); ok {
if ipNet.IP.To4() != nil && !ipNet.IP.IsLoopback() {
hasIPv4 = true
break
}
}
}
if hasIPv4 {
log.Printf("mDNS: Using IPv4 interface: %s", iface.Name)
return &iface
}
}
log.Printf("mDNS: No suitable IPv4 interface found")
return nil
}
+2 -2
View File
@@ -51,8 +51,8 @@ func TestMDNSDiscoverDevices(t *testing.T) {
t.Error("Device name should not be empty")
}
if device.Location == "" {
t.Error("Device location should not be empty")
if device.InfoURL == "" {
t.Error("Device info URL should not be empty")
}
}
}
+218 -14
View File
@@ -1,8 +1,116 @@
// 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 (
"context"
"fmt"
"strings"
"sync"
"time"
@@ -72,7 +180,8 @@ func (u *UnifiedDiscoveryService) DiscoverDevices(ctx context.Context) ([]*model
go func() {
defer wg.Done()
devices, err := u.ssdpService.DiscoverDevices(ctx)
// Use PerformDiscovery directly to avoid double-adding configured devices
devices, err := u.ssdpService.PerformDiscovery(ctx)
if err == nil {
ssdpChan <- devices
} else {
@@ -212,26 +321,121 @@ func (u *UnifiedDiscoveryService) getConfiguredDevices() []*models.DiscoveredDev
return u.config.GetPreferredDevicesAsDiscovered()
}
// mergeDevices merges two device lists, avoiding duplicates based on host
// mergeDevices merges two device lists, combining protocol-specific data when same device found via multiple methods
func (u *UnifiedDiscoveryService) mergeDevices(existing, newDevices []*models.DiscoveredDevice) []*models.DiscoveredDevice {
hostSet := make(map[string]bool)
result := make([]*models.DiscoveredDevice, 0, len(existing)+len(newDevices))
deviceMap := make(map[string]*models.DiscoveredDevice)
// Add existing devices
// Add existing devices to map
for _, device := range existing {
if !hostSet[device.Host] {
result = append(result, device)
hostSet[device.Host] = true
deviceMap[device.Host] = device
}
// Merge new devices, combining protocol-specific data for duplicates
for _, newDevice := range newDevices {
if existingDevice, exists := deviceMap[newDevice.Host]; exists {
// Same device found via different protocol - merge the data
mergedDevice := u.mergeDeviceData(existingDevice, newDevice)
deviceMap[newDevice.Host] = mergedDevice
} else {
// New device
deviceMap[newDevice.Host] = newDevice
}
}
// Add new devices if not already present
for _, device := range newDevices {
if !hostSet[device.Host] {
result = append(result, device)
hostSet[device.Host] = true
}
// Convert map back to slice
result := make([]*models.DiscoveredDevice, 0, len(deviceMap))
for _, device := range deviceMap {
result = append(result, device)
}
return result
}
// mergeDeviceData combines data from two DiscoveredDevice instances representing the same physical device
func (u *UnifiedDiscoveryService) mergeDeviceData(existing, newDevice *models.DiscoveredDevice) *models.DiscoveredDevice {
// Start with the existing device as base
merged := *existing
// Update last seen to the most recent
if newDevice.LastSeen.After(existing.LastSeen) {
merged.LastSeen = newDevice.LastSeen
}
// Prefer more descriptive names
merged.Name = u.pickBestName(existing, newDevice)
// Combine discovery methods
if !strings.Contains(merged.DiscoveryMethod, newDevice.DiscoveryMethod) {
merged.DiscoveryMethod = merged.DiscoveryMethod + "+" + newDevice.DiscoveryMethod
}
// Merge protocol-specific data
u.mergeProtocolData(&merged, newDevice)
// Merge metadata if it exists
u.mergeMetadata(&merged, newDevice)
// Keep model info if available
u.mergeModelInfo(&merged, newDevice)
return &merged
}
func (u *UnifiedDiscoveryService) pickBestName(existing, newDevice *models.DiscoveredDevice) string {
// mDNS usually has better names than SSDP
switch {
case newDevice.DiscoveryMethod == "mDNS/Bonjour" && existing.DiscoveryMethod == "SSDP/UPnP":
return newDevice.Name
case existing.DiscoveryMethod == "Configuration":
// Keep user-configured name
return existing.Name
case newDevice.DiscoveryMethod == "Configuration":
return newDevice.Name
default:
return existing.Name
}
}
func (u *UnifiedDiscoveryService) mergeProtocolData(merged, newDevice *models.DiscoveredDevice) {
if newDevice.UPnPLocation != "" {
merged.UPnPLocation = newDevice.UPnPLocation
}
if newDevice.UPnPUSN != "" {
merged.UPnPUSN = newDevice.UPnPUSN
}
if newDevice.MDNSHostname != "" {
merged.MDNSHostname = newDevice.MDNSHostname
}
if newDevice.MDNSService != "" {
merged.MDNSService = newDevice.MDNSService
}
if newDevice.ConfigName != "" {
merged.ConfigName = newDevice.ConfigName
}
}
func (u *UnifiedDiscoveryService) mergeMetadata(merged, newDevice *models.DiscoveredDevice) {
if merged.Metadata == nil {
merged.Metadata = make(map[string]string)
}
if newDevice.Metadata != nil {
for k, v := range newDevice.Metadata {
merged.Metadata[k] = v
}
}
}
func (u *UnifiedDiscoveryService) mergeModelInfo(merged, newDevice *models.DiscoveredDevice) {
if newDevice.ModelID != "" && merged.ModelID == "" {
merged.ModelID = newDevice.ModelID
}
if newDevice.SerialNo != "" && merged.SerialNo == "" {
merged.SerialNo = newDevice.SerialNo
}
}
+2 -2
View File
@@ -111,8 +111,8 @@ func TestUnifiedDiscoverDevices(t *testing.T) {
t.Error("Device name should not be empty")
}
if device.Location == "" {
t.Error("Device location should not be empty")
if device.InfoURL == "" {
t.Error("Device info URL should not be empty")
}
}
}
+126 -74
View File
@@ -79,7 +79,7 @@ func (d *Service) DiscoverDevices(ctx context.Context) ([]*models.DiscoveredDevi
// Perform UPnP discovery if enabled
if d.config.UPnPEnabled {
upnpDevices, err := d.performDiscovery(ctx)
upnpDevices, err := d.PerformDiscovery(ctx)
if err != nil {
log.Printf("UPnP: Discovery failed: %v", err)
// Don't fail completely if UPnP fails, just log and continue with configured devices
@@ -137,86 +137,35 @@ func (d *Service) ClearCache() {
d.cache = make(map[string]*models.DiscoveredDevice)
}
// performDiscovery performs the actual UPnP SSDP discovery
func (d *Service) performDiscovery(ctx context.Context) ([]*models.DiscoveredDevice, error) {
// PerformDiscovery performs the actual UPnP SSDP discovery
func (d *Service) PerformDiscovery(ctx context.Context) ([]*models.DiscoveredDevice, error) {
log.Printf("UPnP: Starting SSDP discovery for '%s' with timeout %v", soundTouchURN, d.timeout)
// Create UDP connection for multicast
conn, err := net.Dial("udp", ssdpAddr)
listener, err := d.setupUDPListener()
if err != nil {
log.Printf("UPnP: Failed to create UDP connection to %s: %v", ssdpAddr, err)
return nil, fmt.Errorf("failed to create UDP connection: %w", err)
return nil, err
}
defer func() {
_ = conn.Close()
_ = listener.Close()
}()
log.Printf("UPnP: Successfully connected to SSDP multicast address %s", ssdpAddr)
// Send M-SEARCH request
msearchRequest := d.buildMSearchRequest()
log.Printf("UPnP: Sending M-SEARCH request:\n%s", strings.TrimSpace(msearchRequest))
bytesWritten, err := conn.Write([]byte(msearchRequest))
multicastAddr, err := net.ResolveUDPAddr("udp4", ssdpAddr)
if err != nil {
log.Printf("UPnP: Failed to send M-SEARCH request: %v", err)
return nil, fmt.Errorf("failed to send M-SEARCH: %w", err)
log.Printf("UPnP: Failed to resolve multicast address %s: %v", ssdpAddr, err)
return nil, fmt.Errorf("failed to resolve multicast address: %w", err)
}
log.Printf("UPnP: Successfully sent M-SEARCH request (%d bytes)", bytesWritten)
if err = d.sendMSearch(listener, multicastAddr); err != nil {
return nil, err
}
// Listen for responses
devices := make(map[string]*models.DiscoveredDevice)
responseCount := 0
// Set read deadline
deadline := time.Now().Add(d.timeout)
if err := conn.SetReadDeadline(deadline); err != nil {
log.Printf("UPnP: Failed to set read deadline: %v", err)
return nil, fmt.Errorf("failed to set read deadline: %w", err)
}
log.Printf("UPnP: Set read deadline to %v, now listening for responses...", deadline.Format("15:04:05.000"))
buffer := make([]byte, 4096)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
log.Printf("UPnP: Discovery cancelled by context")
return nil, ctx.Err()
default:
n, err := conn.Read(buffer)
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
log.Printf("UPnP: Read timeout reached after %v, stopping discovery", d.timeout)
break // Timeout reached, stop reading
}
log.Printf("UPnP: Error reading response: %v", err)
return nil, fmt.Errorf("failed to read response: %w", err)
}
responseCount++
responseText := string(buffer[:n])
log.Printf("UPnP: Received response #%d (%d bytes):\n%s", responseCount, n, strings.TrimSpace(responseText))
device, err := d.parseResponse(responseText)
if err != nil {
log.Printf("UPnP: Failed to parse response #%d: %v", responseCount, err)
continue // Skip invalid responses
}
if device != nil {
log.Printf("UPnP: Successfully parsed device from response #%d: %s at %s:%d", responseCount, device.Name, device.Host, device.Port)
devices[device.Host] = device
} else {
log.Printf("UPnP: Response #%d did not contain a valid SoundTouch device", responseCount)
}
}
responseCount, err := d.listenForResponses(ctx, listener, devices)
if err != nil {
return nil, err
}
// Convert map to slice
@@ -228,12 +177,111 @@ func (d *Service) performDiscovery(ctx context.Context) ([]*models.DiscoveredDev
log.Printf("UPnP: Discovery completed. Processed %d responses, found %d unique devices", responseCount, len(result))
for i, device := range result {
log.Printf("UPnP: Device #%d: %s at %s:%d (Location: %s)", i+1, device.Name, device.Host, device.Port, device.Location)
log.Printf("UPnP: Device #%d: %s at %s:%d (UPnP Location: %s)", i+1, device.Name, device.Host, device.Port, device.UPnPLocation)
}
return result, nil
}
func (d *Service) setupUDPListener() (*net.UDPConn, error) {
listenAddr, err := net.ResolveUDPAddr("udp4", ":0")
if err != nil {
log.Printf("UPnP: Failed to resolve listen address: %v", err)
return nil, fmt.Errorf("failed to resolve listen address: %w", err)
}
listener, err := net.ListenUDP("udp4", listenAddr)
if err != nil {
log.Printf("UPnP: Failed to create UDP listener: %v", err)
return nil, fmt.Errorf("failed to create UDP listener: %w", err)
}
addr := listener.LocalAddr()
localAddr, ok := addr.(*net.UDPAddr)
if !ok {
_ = listener.Close()
log.Printf("UPnP: Failed to cast local address to UDPAddr: %v", addr)
return nil, fmt.Errorf("failed to cast local address to UDPAddr: %v", addr)
}
log.Printf("UPnP: Created UDP listener on %s", localAddr.String())
return listener, nil
}
func (d *Service) sendMSearch(listener *net.UDPConn, multicastAddr *net.UDPAddr) error {
msearchRequest := d.buildMSearchRequest()
log.Printf("UPnP: Sending M-SEARCH request to %s:\n%s", ssdpAddr, strings.TrimSpace(msearchRequest))
bytesWritten, err := listener.WriteToUDP([]byte(msearchRequest), multicastAddr)
if err != nil {
log.Printf("UPnP: Failed to send M-SEARCH request: %v", err)
return fmt.Errorf("failed to send M-SEARCH: %w", err)
}
log.Printf("UPnP: Successfully sent M-SEARCH request (%d bytes)", bytesWritten)
return nil
}
func (d *Service) listenForResponses(ctx context.Context, listener *net.UDPConn, devices map[string]*models.DiscoveredDevice) (int, error) {
responseCount := 0
// Set read deadline
deadline := time.Now().Add(d.timeout)
if err := listener.SetReadDeadline(deadline); err != nil {
log.Printf("UPnP: Failed to set read deadline: %v", err)
return 0, fmt.Errorf("failed to set read deadline: %w", err)
}
log.Printf("UPnP: Set read deadline to %v, now listening for responses...", deadline.Format("15:04:05.000"))
buffer := make([]byte, 4096)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
log.Printf("UPnP: Discovery cancelled by context")
return responseCount, ctx.Err()
default:
n, remoteAddr, err := listener.ReadFromUDP(buffer)
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
log.Printf("UPnP: Read timeout reached after %v, stopping discovery", d.timeout)
return responseCount, nil
}
log.Printf("UPnP: Error reading response: %v", err)
return responseCount, fmt.Errorf("failed to read response: %w", err)
}
responseCount++
responseText := string(buffer[:n])
log.Printf("UPnP: Received response #%d (%d bytes) from %s:\n%s", responseCount, n, remoteAddr.String(), strings.TrimSpace(responseText))
device, err := d.parseResponse(responseText)
if err != nil {
log.Printf("UPnP: Failed to parse response #%d from %s: %v", responseCount, remoteAddr.String(), err)
continue // Skip invalid responses
}
if device != nil {
log.Printf("UPnP: Successfully parsed device from response #%d: %s at %s:%d", responseCount, device.Name, device.Host, device.Port)
devices[device.Host] = device
} else {
log.Printf("UPnP: Response #%d from %s did not contain a valid SoundTouch device", responseCount, remoteAddr.String())
}
}
}
return responseCount, nil
}
// buildMSearchRequest builds the M-SEARCH request for SoundTouch devices
func (d *Service) buildMSearchRequest() string {
return fmt.Sprintf(
@@ -317,7 +365,7 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro
log.Printf("UPnP: Found Location header: %s", location)
// Extract device information from location URL
device, err := d.parseLocationURL(location)
device, err := d.parseLocationURL(location, headers["usn"])
if err != nil {
log.Printf("UPnP: Failed to parse location URL '%s': %v", location, err)
return nil, fmt.Errorf("failed to parse location URL: %w", err)
@@ -338,7 +386,7 @@ func (d *Service) parseResponse(response string) (*models.DiscoveredDevice, erro
}
// parseLocationURL extracts basic device info from the location URL
func (d *Service) parseLocationURL(location string) (*models.DiscoveredDevice, error) {
func (d *Service) parseLocationURL(location, usn string) (*models.DiscoveredDevice, error) {
log.Printf("UPnP: Parsing location URL: %s", location)
// Parse the URL to extract host and port
@@ -355,11 +403,15 @@ func (d *Service) parseLocationURL(location string) (*models.DiscoveredDevice, e
log.Printf("UPnP: Extracted host='%s', using default port=%d", host, port)
device := &models.DiscoveredDevice{
Host: host,
Port: port,
Location: location,
LastSeen: time.Now(),
Name: fmt.Sprintf("SoundTouch-%s", host), // Default name
Host: host,
Port: port,
LastSeen: time.Now(),
Name: fmt.Sprintf("SoundTouch-%s", host), // Default name
DiscoveryMethod: "SSDP/UPnP",
APIBaseURL: fmt.Sprintf("http://%s:%d/", host, port),
InfoURL: fmt.Sprintf("http://%s:%d/info", host, port),
UPnPLocation: location,
UPnPUSN: usn,
}
return device, nil
+6 -6
View File
@@ -63,7 +63,7 @@ func TestParseLocationURL_Valid(t *testing.T) {
service := NewService(1 * time.Second)
location := "http://192.168.1.100:8090/device.xml"
device, err := service.parseLocationURL(location)
device, err := service.parseLocationURL(location, "")
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
@@ -76,8 +76,8 @@ func TestParseLocationURL_Valid(t *testing.T) {
t.Errorf("Expected port 8090, got %d", device.Port)
}
if device.Location != location {
t.Errorf("Expected location '%s', got '%s'", location, device.Location)
if device.UPnPLocation != location {
t.Errorf("Expected UPnP location '%s', got '%s'", location, device.UPnPLocation)
}
if device.Name == "" {
@@ -101,7 +101,7 @@ func TestParseLocationURL_Invalid(t *testing.T) {
}
for _, url := range invalidURLs {
_, err := service.parseLocationURL(url)
_, err := service.parseLocationURL(url, "")
if err == nil {
t.Errorf("Expected error for invalid URL '%s', got nil", url)
}
@@ -135,8 +135,8 @@ USN: uuid:12345678-1234-5678-9012-123456789012::urn:schemas-upnp-org:device:Medi
t.Errorf("Expected host '192.168.1.100', got '%s'", device.Host)
}
if device.Location != "http://192.168.1.100:8090/device.xml" {
t.Errorf("Expected location 'http://192.168.1.100:8090/device.xml', got '%s'", device.Location)
if device.UPnPLocation != "http://192.168.1.100:8090/device.xml" {
t.Errorf("Expected UPnP location 'http://192.168.1.100:8090/device.xml', got '%s'", device.UPnPLocation)
}
}
+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
+57 -8
View File
@@ -53,13 +53,62 @@ func (e *APIError) Error() string {
return e.Message
}
// DiscoveredDevice represents a device found through UPnP discovery
// DiscoveredDevice represents a device found through network discovery
type DiscoveredDevice struct {
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
ModelID string `json:"model_id"`
SerialNo string `json:"serial_no"`
Location string `json:"location"`
LastSeen time.Time `json:"last_seen"`
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
ModelID string `json:"model_id"`
SerialNo string `json:"serial_no"`
LastSeen time.Time `json:"last_seen"`
DiscoveryMethod string `json:"discovery_method"`
// Standard URLs
APIBaseURL string `json:"api_base_url"` // http://host:port/
InfoURL string `json:"info_url"` // http://host:port/info
// Protocol-specific details
UPnPLocation string `json:"upnp_location,omitempty"` // UPnP device description XML URL
UPnPUSN string `json:"upnp_usn,omitempty"` // UPnP Unique Service Name
MDNSHostname string `json:"mdns_hostname,omitempty"` // mDNS hostname (e.g., "device.local.")
MDNSService string `json:"mdns_service,omitempty"` // mDNS service name
ConfigName string `json:"config_name,omitempty"` // Original name from config
// Additional metadata
Metadata map[string]string `json:"metadata,omitempty"`
}
// GetStandardURLs returns the standard API URLs for this device
func (d *DiscoveredDevice) GetStandardURLs() map[string]string {
return map[string]string{
"base": d.APIBaseURL,
"info": d.InfoURL,
}
}
// GetProtocolSpecificData returns protocol-specific information
func (d *DiscoveredDevice) GetProtocolSpecificData() map[string]interface{} {
data := make(map[string]interface{})
if d.UPnPLocation != "" {
data["upnp"] = map[string]string{
"location": d.UPnPLocation,
"usn": d.UPnPUSN,
}
}
if d.MDNSHostname != "" {
data["mdns"] = map[string]string{
"hostname": d.MDNSHostname,
"service": d.MDNSService,
}
}
if d.ConfigName != "" {
data["config"] = map[string]string{
"original_name": d.ConfigName,
}
}
return data
}
+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