mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-31 14:57:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5673103e0 | ||
|
|
c5a3911104 | ||
|
|
ad43cdaf88 | ||
|
|
fbc09fdc59 |
@@ -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.
|
||||
@@ -145,7 +145,7 @@ go run ./cmd/websocket-demo -host 192.168.1.10 -filter volume,nowPlaying
|
||||
go run ./cmd/websocket-demo -host 192.168.1.10 -duration 5m -verbose
|
||||
|
||||
# Available event types for filtering:
|
||||
# nowPlaying, volume, connection, preset, zone, bass
|
||||
# nowPlaying, volume, connection, preset, zone, bass, sdkInfo, userActivity
|
||||
```
|
||||
|
||||
**Supported WebSocket Events:**
|
||||
@@ -155,6 +155,8 @@ go run ./cmd/websocket-demo -host 192.168.1.10 -duration 5m -verbose
|
||||
- 📻 **Preset**: Preset configuration updates
|
||||
- 🏠 **Zone**: Multiroom zone membership changes
|
||||
- 🎚️ **Bass**: Bass equalizer level adjustments
|
||||
- 📡 **SDK Info**: Server version and build information (sent on connection)
|
||||
- 👤 **User Activity**: User interaction notifications
|
||||
|
||||
See [docs/websocket-events.md](docs/websocket-events.md) for complete WebSocket documentation.
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -68,7 +68,6 @@ func main() {
|
||||
{
|
||||
Name: "name",
|
||||
Usage: "Get or set device name",
|
||||
Flags: CommonFlags,
|
||||
Before: RequireHost,
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
|
||||
@@ -47,6 +47,7 @@ func parseFilters(eventFilter string) map[string]bool {
|
||||
validFilters := map[string]bool{
|
||||
"nowPlaying": true, "volume": true, "connection": true,
|
||||
"preset": true, "zone": true, "bass": true,
|
||||
"sdkInfo": true, "userActivity": true,
|
||||
}
|
||||
|
||||
if eventFilter == "" {
|
||||
@@ -59,7 +60,7 @@ func parseFilters(eventFilter string) map[string]bool {
|
||||
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)
|
||||
fmt.Printf("Invalid filter '%s'. Valid filters: nowPlaying, volume, connection, preset, zone, bass, sdkInfo, userActivity\n", f)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -116,6 +117,9 @@ func setupWebSocket(soundTouchClient *client.Client, reconnect, verbose bool) *c
|
||||
|
||||
if verbose {
|
||||
wsConfig.Logger = &VerboseLogger{}
|
||||
} else {
|
||||
// Use a silent logger when not verbose
|
||||
wsConfig.Logger = &SilentLogger{}
|
||||
}
|
||||
|
||||
if !reconnect {
|
||||
@@ -187,6 +191,11 @@ func main() {
|
||||
// Set up event handlers
|
||||
setupEventHandlers(wsClient, filters, *verbose)
|
||||
|
||||
// Set up special message handler
|
||||
wsClient.OnSpecialMessage(func(message *models.SpecialMessage) {
|
||||
handleSpecialMessage(message, filters, *verbose)
|
||||
})
|
||||
|
||||
// Connect to WebSocket
|
||||
fmt.Println("Connecting to WebSocket...")
|
||||
|
||||
@@ -382,6 +391,43 @@ func handleBass(event *models.BassUpdatedEvent) {
|
||||
fmt.Printf(" 📊 %s\n", levelDesc)
|
||||
}
|
||||
|
||||
func handleSpecialMessage(message *models.SpecialMessage, filters map[string]bool, verbose bool) {
|
||||
// Check if we should filter this message type
|
||||
if filters != nil {
|
||||
switch message.Type {
|
||||
case models.MessageTypeSdkInfo:
|
||||
if !filters["sdkInfo"] {
|
||||
return
|
||||
}
|
||||
case models.MessageTypeUserActivity:
|
||||
if !filters["userActivity"] {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch message.Type {
|
||||
case models.MessageTypeSdkInfo:
|
||||
if sdkInfo := message.GetSdkInfo(); sdkInfo != nil {
|
||||
fmt.Printf("\n📡 SDK Info:\n")
|
||||
fmt.Printf(" 📋 Server Version: %s\n", sdkInfo.ServerVersion)
|
||||
fmt.Printf(" 🔧 Server Build: %s\n", sdkInfo.ServerBuild)
|
||||
}
|
||||
case models.MessageTypeUserActivity:
|
||||
fmt.Printf("\n👤 User Activity [%s]\n", message.DeviceID)
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" ⏰ Timestamp: %s\n", message.Timestamp.Format("15:04:05"))
|
||||
}
|
||||
default:
|
||||
fmt.Printf("\n❓ Unknown Special Message: %s\n", message.String())
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" 📱 Raw data: %s\n", string(message.RawData))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupEventHandlers(wsClient *client.WebSocketClient, filters map[string]bool, verbose bool) {
|
||||
// Now Playing events
|
||||
if filters == nil || filters["nowPlaying"] {
|
||||
@@ -477,7 +523,7 @@ func printHelp() {
|
||||
fmt.Println(" Enable verbose logging")
|
||||
fmt.Println(" -filter string")
|
||||
fmt.Println(" Filter events by type (comma-separated):")
|
||||
fmt.Println(" nowPlaying, volume, connection, preset, zone, bass")
|
||||
fmt.Println(" nowPlaying, volume, connection, preset, zone, bass, sdkInfo, userActivity")
|
||||
fmt.Println(" -help")
|
||||
fmt.Println(" Show this help message")
|
||||
fmt.Println()
|
||||
@@ -501,6 +547,8 @@ func printHelp() {
|
||||
fmt.Println(" 📻 preset - Preset configuration changes")
|
||||
fmt.Println(" 🏠 zone - Multiroom zone changes")
|
||||
fmt.Println(" 🎚️ bass - Bass level changes")
|
||||
fmt.Println(" 📡 sdkInfo - SDK version information")
|
||||
fmt.Println(" 👤 userActivity - User interaction notifications")
|
||||
fmt.Println()
|
||||
fmt.Println("The tool will automatically reconnect if the connection is lost.")
|
||||
fmt.Println("Press Ctrl+C to stop monitoring.")
|
||||
@@ -513,3 +561,10 @@ func (v *VerboseLogger) Printf(format string, args ...interface{}) {
|
||||
timestamp := time.Now().Format("15:04:05")
|
||||
fmt.Printf("[%s] [WebSocket] %s\n", timestamp, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// SilentLogger provides no-op WebSocket logging
|
||||
type SilentLogger struct{}
|
||||
|
||||
func (s *SilentLogger) Printf(_ string, _ ...interface{}) {
|
||||
// Do nothing - silent logging
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
+52
-2
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -153,6 +154,14 @@ func (ws *WebSocketClient) OnUnknownEvent(handler models.EventHandler) {
|
||||
ws.handlers.OnUnknownEvent = handler
|
||||
}
|
||||
|
||||
// OnSpecialMessage sets a handler for special (non-updates) messages
|
||||
func (ws *WebSocketClient) OnSpecialMessage(handler models.SpecialMessageHandler) {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
|
||||
ws.handlers.OnSpecialMessage = handler
|
||||
}
|
||||
|
||||
// Connect establishes a WebSocket connection to the SoundTouch device
|
||||
func (ws *WebSocketClient) Connect() error {
|
||||
return ws.connectWithConfig(DefaultWebSocketConfig())
|
||||
@@ -172,19 +181,26 @@ func (ws *WebSocketClient) connectWithConfig(config *WebSocketConfig) error {
|
||||
}
|
||||
|
||||
// Build WebSocket URL
|
||||
// Parse the base URL to extract just the hostname
|
||||
baseURL, err := url.Parse(ws.client.BaseURL())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse base URL: %w", err)
|
||||
}
|
||||
|
||||
wsURL := url.URL{
|
||||
Scheme: "ws",
|
||||
Host: fmt.Sprintf("%s:%d", ws.client.Host(), 8080), // SoundTouch WebSocket port is typically 8080
|
||||
Host: fmt.Sprintf("%s:8080", baseURL.Hostname()), // SoundTouch WebSocket port is typically 8080
|
||||
Path: "/",
|
||||
}
|
||||
|
||||
ws.logger.Printf("Connecting to %s", wsURL.String())
|
||||
|
||||
// Create dialer with custom buffer sizes
|
||||
// Create dialer with custom buffer sizes and "gabbo" protocol
|
||||
dialer := websocket.Dialer{
|
||||
HandshakeTimeout: 10 * time.Second,
|
||||
ReadBufferSize: config.ReadBufferSize,
|
||||
WriteBufferSize: config.WriteBufferSize,
|
||||
Subprotocols: []string{"gabbo"}, // Required by SoundTouch API
|
||||
}
|
||||
|
||||
// Establish connection
|
||||
@@ -357,6 +373,12 @@ func (ws *WebSocketClient) attemptReconnect(config *WebSocketConfig) {
|
||||
|
||||
// handleMessage processes incoming WebSocket messages
|
||||
func (ws *WebSocketClient) handleMessage(data []byte) {
|
||||
// Check if this is a SoundTouchSdkInfo or other non-updates message
|
||||
if !ws.isUpdatesMessage(data) {
|
||||
ws.handleSpecialMessage(data)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the WebSocket event
|
||||
event, err := models.ParseWebSocketEvent(data)
|
||||
if err != nil {
|
||||
@@ -368,6 +390,34 @@ func (ws *WebSocketClient) handleMessage(data []byte) {
|
||||
ws.handleEvent(event)
|
||||
}
|
||||
|
||||
// handleSpecialMessage processes special (non-updates) WebSocket messages
|
||||
func (ws *WebSocketClient) handleSpecialMessage(data []byte) {
|
||||
specialMessage, err := models.ParseSpecialMessage(data)
|
||||
if err != nil {
|
||||
ws.logger.Printf("Unknown special message type: %v", err)
|
||||
ws.logger.Printf("Raw message: %s", string(data))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Call handler if set
|
||||
ws.mu.RLock()
|
||||
handler := ws.handlers.OnSpecialMessage
|
||||
ws.mu.RUnlock()
|
||||
|
||||
if handler != nil {
|
||||
handler(specialMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// isUpdatesMessage checks if the message contains an <updates> element
|
||||
func (ws *WebSocketClient) isUpdatesMessage(data []byte) bool {
|
||||
// Simple check for <updates> element - this avoids full XML parsing
|
||||
// for messages we want to ignore like <SoundTouchSdkInfo>
|
||||
dataStr := string(data)
|
||||
return strings.Contains(dataStr, "<updates") && strings.Contains(dataStr, "deviceID=")
|
||||
}
|
||||
|
||||
func (ws *WebSocketClient) dispatchTypedEvent(handlers *models.WebSocketEventHandlers, eventType models.WebSocketEventType, event *models.WebSocketEvent) bool {
|
||||
switch eventType {
|
||||
case models.EventTypeNowPlaying:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ func ExampleService_DiscoverDevices() {
|
||||
fmt.Printf("Device: %s\n", device.Name)
|
||||
fmt.Printf(" Address: %s:%d\n", device.Host, device.Port)
|
||||
fmt.Printf(" Serial: %s\n", device.SerialNo)
|
||||
fmt.Printf(" Location: %s\n", device.Location)
|
||||
fmt.Printf(" Info URL: %s\n", device.InfoURL)
|
||||
fmt.Printf(" Host: %s:%d\n", device.Host, device.Port)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
+92
-13
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+111
-14
@@ -110,6 +110,7 @@ package discovery
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -179,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 {
|
||||
@@ -319,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+57
-8
@@ -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
|
||||
}
|
||||
|
||||
+114
-6
@@ -3,6 +3,7 @@ package models
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -298,13 +299,47 @@ type Language struct {
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
// SpecialMessageType represents message types that are not part of <updates>
|
||||
type SpecialMessageType string
|
||||
|
||||
// Constants for special message types
|
||||
const (
|
||||
MessageTypeSdkInfo SpecialMessageType = "sdkInfo"
|
||||
MessageTypeUserActivity SpecialMessageType = "userActivity"
|
||||
)
|
||||
|
||||
// SoundTouchSdkInfo represents the SDK info message sent on connection
|
||||
type SoundTouchSdkInfo struct {
|
||||
XMLName xml.Name `xml:"SoundTouchSdkInfo"`
|
||||
ServerVersion string `xml:"serverVersion,attr"`
|
||||
ServerBuild string `xml:"serverBuild,attr"`
|
||||
}
|
||||
|
||||
// UserActivityUpdate represents user activity notifications
|
||||
type UserActivityUpdate struct {
|
||||
XMLName xml.Name `xml:"userActivityUpdate"`
|
||||
DeviceID string `xml:"deviceID,attr"`
|
||||
}
|
||||
|
||||
// SpecialMessage represents non-updates WebSocket messages
|
||||
type SpecialMessage struct {
|
||||
Type SpecialMessageType
|
||||
DeviceID string
|
||||
Data interface{}
|
||||
RawData []byte
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
// SpecialMessageHandler defines the signature for special message handlers
|
||||
type SpecialMessageHandler func(message *SpecialMessage)
|
||||
|
||||
// EventHandler represents a function that handles WebSocket events
|
||||
type EventHandler func(event *WebSocketEvent)
|
||||
|
||||
// TypedEventHandler represents a function that handles specific event types
|
||||
type TypedEventHandler[T any] func(event T)
|
||||
|
||||
// WebSocketEventHandlers holds typed event handlers for different event types
|
||||
// WebSocketEventHandlers contains handlers for different types of WebSocket events
|
||||
type WebSocketEventHandlers struct {
|
||||
OnNowPlaying TypedEventHandler[*NowPlayingUpdatedEvent]
|
||||
OnVolumeUpdated TypedEventHandler[*VolumeUpdatedEvent]
|
||||
@@ -319,6 +354,7 @@ type WebSocketEventHandlers struct {
|
||||
OnRecentsUpdated TypedEventHandler[*RecentsUpdatedEvent]
|
||||
OnLanguageUpdated TypedEventHandler[*LanguageUpdatedEvent]
|
||||
OnUnknownEvent EventHandler
|
||||
OnSpecialMessage SpecialMessageHandler
|
||||
}
|
||||
|
||||
// ParseWebSocketEvent attempts to parse a WebSocket message into a specific event type
|
||||
@@ -521,16 +557,88 @@ func (e *WebSocketEvent) GetEventTypes() []WebSocketEventType {
|
||||
|
||||
// String returns a human-readable string representation of the WebSocket event
|
||||
func (e *WebSocketEvent) String() string {
|
||||
events := e.GetEvents()
|
||||
eventTypes := e.GetEventTypes()
|
||||
|
||||
if len(events) == 0 {
|
||||
if len(eventTypes) == 0 {
|
||||
return fmt.Sprintf("WebSocket Event [Device: %s] - No events", e.DeviceID)
|
||||
}
|
||||
|
||||
if len(events) == 1 {
|
||||
if len(eventTypes) == 1 {
|
||||
return fmt.Sprintf("WebSocket Event [Device: %s] - %s", e.DeviceID, eventTypes[0].String())
|
||||
}
|
||||
|
||||
return fmt.Sprintf("WebSocket Event [Device: %s] - %d events", e.DeviceID, len(events))
|
||||
return fmt.Sprintf("WebSocket Event [Device: %s] - %d events", e.DeviceID, len(eventTypes))
|
||||
}
|
||||
|
||||
// ParseSpecialMessage parses non-updates WebSocket messages
|
||||
func ParseSpecialMessage(data []byte) (*SpecialMessage, error) {
|
||||
dataStr := string(data)
|
||||
|
||||
// Check for SoundTouchSdkInfo
|
||||
if strings.Contains(dataStr, "<SoundTouchSdkInfo") {
|
||||
var sdkInfo SoundTouchSdkInfo
|
||||
if err := xml.Unmarshal(data, &sdkInfo); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse SoundTouchSdkInfo: %w", err)
|
||||
}
|
||||
|
||||
return &SpecialMessage{
|
||||
Type: MessageTypeSdkInfo,
|
||||
Data: &sdkInfo,
|
||||
RawData: data,
|
||||
Timestamp: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Check for userActivityUpdate
|
||||
if strings.Contains(dataStr, "<userActivityUpdate") {
|
||||
var userActivity UserActivityUpdate
|
||||
if err := xml.Unmarshal(data, &userActivity); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse userActivityUpdate: %w", err)
|
||||
}
|
||||
|
||||
return &SpecialMessage{
|
||||
Type: MessageTypeUserActivity,
|
||||
DeviceID: userActivity.DeviceID,
|
||||
Data: &userActivity,
|
||||
RawData: data,
|
||||
Timestamp: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unknown special message type: %s", dataStr)
|
||||
}
|
||||
|
||||
// GetSdkInfo returns the parsed SdkInfo data if the message is of that type
|
||||
func (sm *SpecialMessage) GetSdkInfo() *SoundTouchSdkInfo {
|
||||
if sm.Type == MessageTypeSdkInfo {
|
||||
if sdkInfo, ok := sm.Data.(*SoundTouchSdkInfo); ok {
|
||||
return sdkInfo
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserActivity returns the parsed UserActivity data if the message is of that type
|
||||
func (sm *SpecialMessage) GetUserActivity() *UserActivityUpdate {
|
||||
if sm.Type == MessageTypeUserActivity {
|
||||
if userActivity, ok := sm.Data.(*UserActivityUpdate); ok {
|
||||
return userActivity
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// String returns a string representation of the special message
|
||||
func (sm *SpecialMessage) String() string {
|
||||
switch sm.Type {
|
||||
case MessageTypeSdkInfo:
|
||||
if sdkInfo := sm.GetSdkInfo(); sdkInfo != nil {
|
||||
return fmt.Sprintf("SoundTouch SDK Info - Version: %s, Build: %s", sdkInfo.ServerVersion, sdkInfo.ServerBuild)
|
||||
}
|
||||
case MessageTypeUserActivity:
|
||||
return fmt.Sprintf("User Activity [Device: %s]", sm.DeviceID)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Unknown Special Message - Type: %s", sm.Type)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user