mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
On a multi-homed host the discovery layer used to walk net.Interfaces()
and pick the first non-loopback IPv4 NIC, while UPnP/SSDP bound a
wildcard UDP socket and let the kernel route the multicast send. That
meant --bind on soundtouch-web only moved the HTTP listener; the
discovery still went out whatever interface the kernel preferred (often
the wrong one on hosts where the speakers sit behind a secondary NIC).
Introduce a separate DiscoveryInterface knob:
* pkg/config: DiscoveryInterface field + DISCOVERY_INTERFACE env var.
* pkg/discovery/mdns: NewMDNSDiscoveryServiceWithInterface; the
interface resolver now honours an explicit name and validates it
has a usable IPv4 address before handing it to hashicorp/mdns.
* pkg/discovery/upnp: when an interface is configured, bind the UDP
socket's source IP to the NIC's IPv4 and call
ipv4.PacketConn.SetMulticastInterface so M-SEARCH leaves the right
NIC. Without an interface, behaviour is unchanged.
* cmd/soundtouch-web: new --interface flag (DISCOVERY_INTERFACE env)
plumbed into the config before the discovery service is built.
go.mod/go.sum reflect promoting golang.org/x/net from indirect to a
direct dependency (now imported for ipv4.PacketConn).
Refs #264.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
286 lines
7.6 KiB
Go
286 lines
7.6 KiB
Go
// Package config provides configuration management for the Bose SoundTouch Go library.
|
|
package config
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gesellix/bose-soundtouch/pkg/models"
|
|
)
|
|
|
|
// Config holds configuration for the SoundTouch application
|
|
type Config struct {
|
|
// Discovery settings
|
|
DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"5s"`
|
|
UPnPEnabled bool `env:"UPNP_ENABLED" default:"true"`
|
|
MDNSEnabled bool `env:"MDNS_ENABLED" default:"true"`
|
|
// DiscoveryInterface restricts mDNS and UPnP/SSDP discovery to a single
|
|
// network interface (e.g. "eth0"). Empty means "auto-pick the first
|
|
// suitable interface", which is the historical behaviour.
|
|
DiscoveryInterface string `env:"DISCOVERY_INTERFACE" default:""`
|
|
|
|
// Preferred devices from .env file
|
|
PreferredDevices []DeviceConfig `env:"PREFERRED_DEVICES"`
|
|
|
|
// HTTP Client settings
|
|
HTTPTimeout time.Duration `env:"HTTP_TIMEOUT" default:"10s"`
|
|
UserAgent string `env:"USER_AGENT" default:"Bose-SoundTouch-Go-Client/1.0"`
|
|
|
|
// Cache settings
|
|
CacheEnabled bool `env:"CACHE_ENABLED" default:"true"`
|
|
CacheTTL time.Duration `env:"CACHE_TTL" default:"30s"`
|
|
|
|
// Migration settings (TODO: Remove after 3-4 releases when all devices are migrated)
|
|
MigrationEnabled bool `env:"MIGRATION_ENABLED" default:"true"`
|
|
MigrationDryRun bool `env:"MIGRATION_DRY_RUN" default:"false"`
|
|
}
|
|
|
|
// DeviceConfig represents a configured SoundTouch device
|
|
type DeviceConfig struct {
|
|
Name string `json:"name"`
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
}
|
|
|
|
// DefaultConfig returns a configuration with default values
|
|
func DefaultConfig() *Config {
|
|
return &Config{
|
|
DiscoveryTimeout: 5 * time.Second,
|
|
UPnPEnabled: true,
|
|
MDNSEnabled: true,
|
|
PreferredDevices: []DeviceConfig{},
|
|
HTTPTimeout: 10 * time.Second,
|
|
UserAgent: "Bose-SoundTouch-Go-Client/1.0",
|
|
MigrationEnabled: true, // TODO: Change to false after 3-4 releases
|
|
MigrationDryRun: false,
|
|
CacheEnabled: true,
|
|
CacheTTL: 30 * time.Second,
|
|
}
|
|
}
|
|
|
|
// LoadFromEnv loads configuration from environment variables and .env file
|
|
func LoadFromEnv() (*Config, error) {
|
|
config := DefaultConfig()
|
|
|
|
// Load .env file if it exists
|
|
_ = loadDotEnv() // Don't fail if .env doesn't exist, just continue with defaults
|
|
|
|
// Parse environment variables
|
|
if timeout := os.Getenv("DISCOVERY_TIMEOUT"); timeout != "" {
|
|
if d, err := time.ParseDuration(timeout); err == nil {
|
|
config.DiscoveryTimeout = d
|
|
}
|
|
}
|
|
|
|
if upnp := os.Getenv("UPNP_ENABLED"); upnp != "" {
|
|
config.UPnPEnabled = upnp == "true" || upnp == "1"
|
|
}
|
|
|
|
if mdns := os.Getenv("MDNS_ENABLED"); mdns != "" {
|
|
config.MDNSEnabled = mdns == "true" || mdns == "1"
|
|
}
|
|
|
|
if iface := os.Getenv("DISCOVERY_INTERFACE"); iface != "" {
|
|
config.DiscoveryInterface = iface
|
|
}
|
|
|
|
if timeout := os.Getenv("HTTP_TIMEOUT"); timeout != "" {
|
|
if d, err := time.ParseDuration(timeout); err == nil {
|
|
config.HTTPTimeout = d
|
|
}
|
|
}
|
|
|
|
if userAgent := os.Getenv("USER_AGENT"); userAgent != "" {
|
|
config.UserAgent = userAgent
|
|
}
|
|
|
|
if cache := os.Getenv("CACHE_ENABLED"); cache != "" {
|
|
config.CacheEnabled = cache == "true" || cache == "1"
|
|
}
|
|
|
|
if cacheTTL := os.Getenv("CACHE_TTL"); cacheTTL != "" {
|
|
if d, err := time.ParseDuration(cacheTTL); err == nil {
|
|
config.CacheTTL = d
|
|
}
|
|
}
|
|
|
|
// Parse preferred devices
|
|
devices, err := parsePreferredDevices()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse preferred devices: %w", err)
|
|
}
|
|
|
|
config.PreferredDevices = devices
|
|
|
|
return config, nil
|
|
}
|
|
|
|
// GetPreferredDevicesAsDiscovered converts configured devices to DiscoveredDevice format
|
|
func (c *Config) GetPreferredDevicesAsDiscovered() []*models.DiscoveredDevice {
|
|
devices := make([]*models.DiscoveredDevice, 0, len(c.PreferredDevices))
|
|
|
|
for _, device := range c.PreferredDevices {
|
|
discovered := &models.DiscoveredDevice{
|
|
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)
|
|
}
|
|
|
|
return devices
|
|
}
|
|
|
|
// loadDotEnv loads variables from .env file
|
|
func loadDotEnv() error {
|
|
file, err := os.Open(".env")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
|
|
scanner := bufio.NewScanner(file)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
|
|
// Skip empty lines and comments
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
|
|
// Parse key=value pairs
|
|
parts := strings.SplitN(line, "=", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
|
|
key := strings.TrimSpace(parts[0])
|
|
value := strings.TrimSpace(parts[1])
|
|
|
|
// Remove quotes if present
|
|
if len(value) >= 2 {
|
|
if (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) ||
|
|
(strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) {
|
|
value = value[1 : len(value)-1]
|
|
}
|
|
}
|
|
|
|
// Set environment variable if not already set
|
|
if os.Getenv(key) == "" {
|
|
_ = os.Setenv(key, value)
|
|
}
|
|
}
|
|
|
|
return scanner.Err()
|
|
}
|
|
|
|
// parsePreferredDevices parses PREFERRED_DEVICES from environment
|
|
func parsePreferredDevices() ([]DeviceConfig, error) {
|
|
devicesEnv := os.Getenv("PREFERRED_DEVICES")
|
|
if devicesEnv == "" {
|
|
return []DeviceConfig{}, nil
|
|
}
|
|
|
|
var devices []DeviceConfig
|
|
|
|
// Split by semicolon for multiple devices
|
|
deviceStrings := strings.Split(devicesEnv, ";")
|
|
|
|
for _, deviceStr := range deviceStrings {
|
|
deviceStr = strings.TrimSpace(deviceStr)
|
|
if deviceStr == "" {
|
|
continue
|
|
}
|
|
|
|
device, err := parseDeviceString(deviceStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid device configuration '%s': %w", deviceStr, err)
|
|
}
|
|
|
|
devices = append(devices, device)
|
|
}
|
|
|
|
return devices, nil
|
|
}
|
|
|
|
// parseDeviceString parses a single device string in format "name@host:port" or "host:port" or "host"
|
|
func parseDeviceString(deviceStr string) (DeviceConfig, error) {
|
|
device := DeviceConfig{
|
|
Port: 8090, // Default SoundTouch port
|
|
}
|
|
|
|
// Check if name is specified (name@host:port)
|
|
if strings.Contains(deviceStr, "@") {
|
|
parts := strings.SplitN(deviceStr, "@", 2)
|
|
device.Name = strings.TrimSpace(parts[0])
|
|
deviceStr = strings.TrimSpace(parts[1])
|
|
}
|
|
|
|
// Parse host:port or just host
|
|
if strings.Contains(deviceStr, ":") {
|
|
host, portStr, err := net.SplitHostPort(deviceStr)
|
|
if err != nil {
|
|
return device, fmt.Errorf("invalid host:port format: %w", err)
|
|
}
|
|
|
|
port, err := strconv.Atoi(portStr)
|
|
if err != nil || port <= 0 || port > 65535 {
|
|
return device, fmt.Errorf("invalid port number: %s", portStr)
|
|
}
|
|
|
|
device.Host = host
|
|
device.Port = port
|
|
} else {
|
|
device.Host = deviceStr
|
|
}
|
|
|
|
// Validate host
|
|
if device.Host == "" {
|
|
return device, fmt.Errorf("host cannot be empty")
|
|
}
|
|
|
|
// Set default name if not provided
|
|
if device.Name == "" {
|
|
device.Name = fmt.Sprintf("SoundTouch-%s", device.Host)
|
|
}
|
|
|
|
return device, nil
|
|
}
|
|
|
|
// Validate checks if the configuration is valid
|
|
func (c *Config) Validate() error {
|
|
if c.DiscoveryTimeout <= 0 {
|
|
return fmt.Errorf("discovery timeout must be positive")
|
|
}
|
|
|
|
if c.HTTPTimeout <= 0 {
|
|
return fmt.Errorf("HTTP timeout must be positive")
|
|
}
|
|
|
|
if c.CacheTTL <= 0 {
|
|
return fmt.Errorf("cache TTL must be positive")
|
|
}
|
|
|
|
for i, device := range c.PreferredDevices {
|
|
if device.Host == "" {
|
|
return fmt.Errorf("device %d: host cannot be empty", i)
|
|
}
|
|
|
|
if device.Port <= 0 || device.Port > 65535 {
|
|
return fmt.Errorf("device %d: invalid port %d", i, device.Port)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|